• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bleedingdeacons / confur / 31015112238

05 Aug 2026 02:25PM UTC coverage: 95.194% (-0.1%) from 95.299%
31015112238

push

github

web-flow
chore(tooling): make PSR-12 a gate and badge the standard (#50)

* chore(tooling): exclude WordPress and test-only sniffs from PSR-12

Groundwork for making PHPCS a gate. No formatting changes here - this commit
only narrows the ruleset, adds the .gitattributes this repo was missing, and
promotes the CI step, so the reformatting that follows is reviewable on its
own.

* style: reindent with spaces and give five methods a visibility keyword

This plugin was tab-indented throughout while the rest of the suite is not,
which accounted for 2,116 of its findings across 18 files - more than a
quarter of the whole suite's. phpcbf did that reindent plus the usual
structural tidying: opening braces onto their own line, inline
if (...) { return x; } expanded, multi-line call arguments aligned,
trailing blank lines before closing braces dropped.

Not purely whitespace, so git diff -w on this commit is not empty. The
non-whitespace part is the brace and inline-control-structure restructuring
above, plus five methods that were declared with no visibility keyword and
a snake_case name:

  AcfHelper::update_acf_field   -> public static function updateAcfField
  AcfHelper::update_acf_fields  -> public static function updateAcfFields
  AcfHelper::update_acf_field2  -> public static function updateAcfField2
  AnswerShortcode::get_field    -> public function getField
  AnswerShortcode::get_the_ID   -> public function getTheId

The AcfHelper three are called only from their own tests and one commented
line in AnswerHandler; both were updated. The AnswerShortcode two are thin
wrappers that exist so the global ACF/WP calls can be swapped out in tests -
they kept their // Calls global comments, and their two internal callers
in the same class were updated. No behaviour changes.

Two file-level docblocks in GeneralShortcodes and HtmlHelper sat between a
class_exists() guard and the class they describe, which PSR-12 reads as a
misplaced file docblock. Each moved inside its gua... (continued)

1022 of 1111 new or added lines in 21 files covered. (91.99%)

3 existing lines in 3 files now uncovered.

2258 of 2372 relevant lines covered (95.19%)

4.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.76
/src/Repositories/AnswerRepository.php
1
<?php
2

3
namespace Confur\Repositories;
4

5
use Confur\Config\Constants;
6

7
/**
8
 * Repository for answer data operations
9
 */
10
class AnswerRepository
11
{
12
    /**
13
     * Get value from ACF field
14
     *
15
     * @param string $name Field name
16
     * @return string Sanitized value
17
     */
18
    public function getValue(string $name): string
1 ✔
19
    {
20
        $value = get_field($name);
1 ✔
21
        return sanitize_textarea_field($value);
1 ✔
22
    }
23

24
    /**
25
     * Get answer status
26
     *
27
     * @param int $postId Post ID
28
     * @return array<string, mixed> Status data
29
     */
30
    public function getAnswerStatus(int $postId): array
2 ✔
31
    {
32
        $status = get_field(Constants::STATUS_FIELD, $postId);
2 ✔
33

34
        error_log($status);
2 ✔
35

36
        if (empty($status)) {
2 ✔
37
            update_field(Constants::STATUS_FIELD, Constants::STATUS_DRAFT, $postId);
1 ✔
38
            acf_save_post();
1 ✔
39
            $status = get_field(Constants::STATUS_FIELD, $postId);
1 ✔
40
        }
41

42
        $updated = get_field(Constants::UPDATED_FIELD, $postId);
2 ✔
43

44
        if (empty($updated)) {
2 ✔
45
            $updated = 'N/A';
1 ✔
46
        }
47

48
        return [
2 ✔
49
//          'id' => $postId,
50
            'state' => $status,
2 ✔
51
            'updated' => $updated
2 ✔
52
        ];
2 ✔
53
    }
54

55
    /**
56
     * Get all answer posts
57
     *
58
     * @return array<int, int> Answer post IDs
59
     */
60
    public function getAllAnswers(): array
5 ✔
61
    {
62
        $all = get_posts([
5 ✔
63
            'post_type' => Constants::ANSWER_CUSTOM_TYPE,
5 ✔
64
            'posts_per_page' => -1,
5 ✔
65
            'post_status' => ['publish', 'draft', 'pending', 'private'],
5 ✔
66
            'fields' => 'ids'
5 ✔
67
        ]);
5 ✔
68

69
        return array_filter($all);
5 ✔
70
    }
71

72
    /**
73
     * Get registered groups
74
     *
75
     * @return array<int, array<string, mixed>> Registered groups data
76
     */
77
    public function getRegisteredGroups(): array
2 ✔
78
    {
79
        $registered = [];
2 ✔
80
        $all = $this->getAllAnswers();
2 ✔
81

82
        foreach ($all as $postId) {
2 ✔
83
            $meeting = get_field(Constants::MEETING_FIELD, $postId);
2 ✔
84
            $fellow_meeting = get_field(Constants::FELLOW_MEETING_FIELD, $postId);
2 ✔
85
            $email = get_field(Constants::EMAIL_FIELD, $postId);
2 ✔
86
            $updated = get_field(Constants::UPDATED_FIELD, $postId);
2 ✔
87
            $status = get_field(Constants::STATUS_FIELD, $postId);
2 ✔
88

89
            // Normalize meeting IDs (handle objects/arrays from ACF)
90
            $meetingId = $this->normalizePostId($meeting);
2 ✔
91
            $fellowMeetingId = $this->normalizePostId($fellow_meeting);
2 ✔
92

93
            // Add entry if primary meeting exists (includes fellow_meeting if present)
94
            if (!empty($meetingId)) {
2 ✔
95
                $registered[] = [
2 ✔
96
                    'answersId' => $postId,
2 ✔
97
                    'meetingId' => $meetingId,
2 ✔
98
                    'fellowMeetingId' => $fellowMeetingId,
2 ✔
99
                    'email' => $email,
2 ✔
100
                    'updated' => $updated,
2 ✔
101
                    'status' => $status
2 ✔
102
                ];
2 ✔
103
            }
104
        }
105

106
        return $registered;
2 ✔
107
    }
108

109
    /**
110
     * Find duplicate registration by meeting, fellow_meeting, and email
111
     * Handles both paths - checks if the meeting/fellow_meeting combination matches
112
     * in either order (A+B or B+A)
113
     * Only compares answers with both meetings against other answers with both meetings
114
     * Returns the latest duplicate based on the 'updated' field if multiple exist
115
     *
116
     * @param int|null $meetingId Meeting post ID
117
     * @param int|null $fellowMeetingId Fellow meeting post ID (can be null)
118
     * @param string $email Email address
119
     * @param int|null $excludePostId Post ID to exclude from search (the newly created post)
120
     * @return array<string, mixed>|null Array with 'post_id' and 'slug' if duplicate found, null otherwise
121
     */
122
    public function findDuplicate(?int $meetingId, ?int $fellowMeetingId, string $email, ?int $excludePostId = null): ?array
10 ✔
123
    {
124
        error_log("AnswerRepository::findDuplicate - Called with meetingId: " . ($meetingId ?? 'null') . ", fellowMeetingId: " . ($fellowMeetingId ?? 'null') . ", email: $email, excludePostId: " . ($excludePostId ?? 'null'));
10 ✔
125

126
        if (empty($meetingId) || empty($email)) {
10 ✔
127
            error_log("AnswerRepository::findDuplicate - Returning null due to empty meetingId or email");
1 ✔
128
            return null;
1 ✔
129
        }
130

131
        // Determine if this is a paired registration (has both meetings)
132
        $isPairedRegistration = !empty($fellowMeetingId);
9 ✔
133

134
        // Get all answer posts and filter in PHP - more reliable with ACF fields
135
        $args = [
9 ✔
136
            'post_type' => Constants::ANSWER_CUSTOM_TYPE,
9 ✔
137
            'posts_per_page' => -1,
9 ✔
138
            'post_status' => ['publish', 'draft', 'pending', 'private'],
9 ✔
139
            'fields' => 'ids'
9 ✔
140
        ];
9 ✔
141

142
        // Exclude the newly created post from the search
143
        if ($excludePostId) {
9 ✔
144
            $args['post__not_in'] = [$excludePostId];
9 ✔
145
        }
146

147
        $allPosts = get_posts($args);
9 ✔
148

149
        error_log("AnswerRepository::findDuplicate - Found " . count($allPosts) . " total answer posts to check");
9 ✔
150

151
        // Build the set of meeting IDs from the new registration
152
        $inputMeetingIds = [$meetingId];
9 ✔
153
        if ($isPairedRegistration) {
9 ✔
154
            $inputMeetingIds[] = $fellowMeetingId;
2 ✔
155
        }
156
        sort($inputMeetingIds);
9 ✔
157

158
        // Collect all matching duplicates
159
        /** @var array<int, array{post_id: int, updated: string}> $duplicates */
160
        $duplicates = [];
9 ✔
161

162
        foreach ($allPosts as $postId) {
9 ✔
163
            // Get ACF fields and normalize them
164
            $postMeeting = $this->normalizePostId(get_field(Constants::MEETING_FIELD, $postId));
8 ✔
165
            $postFellowMeeting = $this->normalizePostId(get_field(Constants::FELLOW_MEETING_FIELD, $postId));
8 ✔
166
            $postEmail = get_field(Constants::EMAIL_FIELD, $postId);
8 ✔
167
            $postStatus = get_field(Constants::STATUS_FIELD, $postId);
8 ✔
168
            // Cast at the source: get_field() is mixed, and the usort below
169
            // feeds this straight to strtotime(), which wants a string.
170
            $postUpdated = (string) (get_field(Constants::UPDATED_FIELD, $postId) ?? '');
8 ✔
171

172
            error_log("AnswerRepository::findDuplicate - Checking post $postId: meeting=$postMeeting, fellow=$postFellowMeeting, email=$postEmail, status=$postStatus, updated=$postUpdated");
8 ✔
173

174
            // Skip cancelled registrations
175
            if ($postStatus === Constants::STATUS_CANCELLED) {
8 ✔
176
                continue;
1 ✔
177
            }
178

179
            // Check email match (case-insensitive)
180
            if (strtolower($postEmail) !== strtolower($email)) {
7 ✔
181
                continue;
1 ✔
182
            }
183

184
            // Determine if existing post is a paired registration
185
            $isPostPaired = !empty($postFellowMeeting);
6 ✔
186

187
            // Only compare paired with paired, and single with single
188
            if ($isPairedRegistration !== $isPostPaired) {
6 ✔
189
                continue;
1 ✔
190
            }
191

192
            // Build the set of meeting IDs from the existing post
193
            $postMeetingIds = [];
5 ✔
194
            if (!empty($postMeeting)) {
5 ✔
195
                $postMeetingIds[] = $postMeeting;
5 ✔
196
            }
197
            if (!empty($postFellowMeeting)) {
5 ✔
198
                $postMeetingIds[] = $postFellowMeeting;
1 ✔
199
            }
200
            sort($postMeetingIds);
5 ✔
201

202
            // Check if the meeting combinations match (handles swapped order for paired)
203
            if ($inputMeetingIds !== $postMeetingIds) {
5 ✔
204
                continue;
1 ✔
205
            }
206

207
            // Found a duplicate - add to list
208
            $duplicates[] = [
4 ✔
209
                'post_id' => $postId,
4 ✔
210
                'updated' => $postUpdated
4 ✔
211
            ];
4 ✔
212
        }
213

214
        if (empty($duplicates)) {
9 ✔
215
            error_log("AnswerRepository::findDuplicate - No duplicate found");
5 ✔
216
            return null;
5 ✔
217
        }
218

219
        // Sort duplicates by updated date (latest first), fall back to post creation date
220
        usort($duplicates, function ($a, $b) {
4 ✔
221
            // Handle empty/null updated values - fall back to post creation date
222
            $aUpdated = !empty($a['updated']) ? strtotime($a['updated']) : 0;
2 ✔
223
            $bUpdated = !empty($b['updated']) ? strtotime($b['updated']) : 0;
2 ✔
224

225
            // If both have updated dates, compare them
226
            if ($aUpdated > 0 && $bUpdated > 0) {
2 ✔
227
                return $bUpdated - $aUpdated; // Descending order (latest first)
1 ✔
228
            }
229

230
            // If only one has updated date, prefer the one with updated date
231
            if ($aUpdated > 0) {
1 ✔
NEW
232
                return -1;
×
233
            }
234
            if ($bUpdated > 0) {
1 ✔
NEW
235
                return 1;
×
236
            }
237

238
            // If neither has updated date, fall back to post creation date
239
            $aPost = get_post($a['post_id']);
1 ✔
240
            $bPost = get_post($b['post_id']);
1 ✔
241
            $aCreated = strtotime($aPost->post_date);
1 ✔
242
            $bCreated = strtotime($bPost->post_date);
1 ✔
243

244
            return $bCreated - $aCreated; // Descending order (latest first)
1 ✔
245
        });
4 ✔
246

247
        // Return the latest duplicate
248
        $latestDuplicate = $duplicates[0];
4 ✔
249
        $post = get_post($latestDuplicate['post_id']);
4 ✔
250

251
        error_log("AnswerRepository::findDuplicate - Found " . count($duplicates) . " duplicate(s), returning latest: post " . $latestDuplicate['post_id']);
4 ✔
252

253
        return [
4 ✔
254
            'post_id' => $latestDuplicate['post_id'],
4 ✔
255
            'slug' => $post->post_name
4 ✔
256
        ];
4 ✔
257
    }
258

259
    /**
260
     * Normalize post ID from ACF field value
261
     * ACF can return post ID as int, object, or array depending on configuration
262
     *
263
     * @param mixed $value ACF field value
264
     * @return int|null Post ID or null
265
     */
266
    private function normalizePostId($value): ?int
11 ✔
267
    {
268
        if (empty($value)) {
11 ✔
269
            return null;
10 ✔
270
        }
271

272
        // Already an integer
273
        if (is_numeric($value)) {
11 ✔
274
            return (int)$value;
10 ✔
275
        }
276

277
        // Post object
278
        if (is_object($value) && isset($value->ID)) {
1 ✔
279
            return (int)$value->ID;
1 ✔
280
        }
281

282
        // Array with ID key
283
        if (is_array($value) && isset($value['ID'])) {
1 ✔
284
            return (int)$value['ID'];
1 ✔
285
        }
286

287
        return null;
1 ✔
288
    }
289

290
    /**
291
     * Get group answers
292
     *
293
     * @return array<string, mixed> Group answers data
294
     */
295
//  public function getGroupAnswers(): array
296
//  {
297
//      $answers = [];
298
//      $all = $this->getAllAnswers();
299
//
300
//      foreach ($all as $postId) {
301
//          $meeting = get_field(Constants::MEETING_FIELD, $postId);
302
//          $email = get_field(Constants::EMAIL_FIELD, $postId);
303
//          $updated = get_field(Constants::UPDATED_FIELD, $postId);
304
//          $status = get_field(Constants::STATUS_FIELD, $postId);
305
//
306
//          if (!empty($updated)) {
307
//              $allFields = get_fields($postId);
308
//
309
//              foreach ($allFields as $fieldName => $fieldValue) {
310
//                  if (str_starts_with($fieldName, 'c')) {
311
//                      foreach ($fieldValue as $questionName => $answer) {
312
//                          if (!empty($answer)) {
313
//                              $meetingName = get_the_title($meeting);
314
//                              $resultUrl = get_permalink($postId);
315
//
316
//                              $groupAnswer = [
317
//                                  $meeting,
318
//                                  $meetingName,
319
//                                  $resultUrl,
320
//                                  $email,
321
//                                  $updated,
322
//                                  $answer,
323
//                                  $status
324
//                              ];
325
//
326
//                              $answers[$fieldName . '_' . $questionName][] = $groupAnswer;
327
//                          }
328
//                      }
329
//                  }
330
//              }
331
//          }
332
//      }
333
//
334
//      return $answers;
335
//  }
336

337
    /**
338
     * Get group answers
339
     *
340
     * @return array<string, mixed> Group answers data
341
     */
342
    public function getGroupAnswers(): array
2 ✔
343
    {
344
        $answers = [];
2 ✔
345
        $all = $this->getAllAnswers();
2 ✔
346

347
        foreach ($all as $postId) {
2 ✔
348
            // Skip posts that are in the trash
349
            if (get_post_status($postId) === 'trash') {
2 ✔
350
                continue;
1 ✔
351
            }
352

353
            $meeting = get_field(Constants::MEETING_FIELD, $postId);
1 ✔
354
            $fellow_meeting = get_field(Constants::FELLOW_MEETING_FIELD, $postId);
1 ✔
355
            $email = get_field(Constants::EMAIL_FIELD, $postId);
1 ✔
356
            $updated = get_field(Constants::UPDATED_FIELD, $postId);
1 ✔
357
            $status = get_field(Constants::STATUS_FIELD, $postId);
1 ✔
358

359
            // Normalize meeting IDs
360
            $meetingId = $this->normalizePostId($meeting);
1 ✔
361
            $fellowMeetingId = $this->normalizePostId($fellow_meeting);
1 ✔
362

363
            if (!empty($updated)) {
1 ✔
364
                $allFields = get_fields($postId);
1 ✔
365

366
                foreach ($allFields as $fieldName => $fieldValue) {
1 ✔
367
                    if (preg_match('/^c\d+_/', $fieldName)) {
1 ✔
368
                        $answer = $fieldValue;
1 ✔
369

370
                        if (!empty($answer)) {
1 ✔
371
                            $meetingName = get_the_title($meetingId);
1 ✔
372
                            $resultUrl = get_permalink($postId);
1 ✔
373

374
                            $groupAnswer = [
1 ✔
375
                                'meetingId' => $meetingId,
1 ✔
376
                                'fellowMeetingId' => $fellowMeetingId,
1 ✔
377
                                'meetingName' => $meetingName,
1 ✔
378
                                'resultUrl' => $resultUrl,
1 ✔
379
                                'email' => $email,
1 ✔
380
                                'updated' => $updated,
1 ✔
381
                                'answer' => $answer,
1 ✔
382
                                'status' => $status
1 ✔
383
                            ];
1 ✔
384

385
                            $answers[$fieldName][] = $groupAnswer;
1 ✔
386
                        }
387
                    }
388
                }
389
            }
390
        }
391

392
        return $answers;
2 ✔
393
    }
394
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc