Skip to content

Status note (2026-09-25): Dated implementation tracker. Approved decisions remain evidence, but phase, branch and test status require current repository and issue verification.

Clipper Groups implementation tracker ​

TL;DR: build log for the Clipper Groups admin feature (phases, branches, test status as of 2026-08-30). Its decisions are evidence; treat phase and branch status as historical and check current repository state and issues.

Last updated: 2026-08-30 (Phase 5, campaign scope, ready for review)

Ownership ​

These three statements are authoritative and override any older wording elsewhere in this document:

  • Domain ownership: Campaign owns ClipperGroup. Every group belongs to exactly one campaign, chosen at creation and immutable thereafter.
  • Admin UX ownership: Clipper Groups section owns creation and management. Campaign pages link to Clipper Groups and do nothing else with groups.
  • Codex orchestrates this task; Claude executes the implementation. Codex reviews, runs all shell commands, and performs all validation.

Objective and source ​

Build the generic Clipper Groups foundation across the BloxClips frontend and backend without coupling it to the PV Tracker. The work is tracked by backend issue #2, Introduce Clipper Groups domain foundation.

The issue was re-read during Phase 0 and was open at the time of reconnaissance. Frontend pull request #3, Add Content Rewards design system page is an open visual reference only. Its feature/frontend-design-system-playbook branch will not be merged into, or used as the base of, this feature.

Work proceeds one review-gated phase at a time. No later phase starts until the current READY FOR REVIEW phase is reviewed.

Repository and worktree metadata ​

Both repositories were fetched from origin dev before worktree creation on 2026-08-29. Neither remote tip advanced relative to the SHAs recorded during planning.

RepositoryExisting developer worktreeExisting branch and HEADFetched origin/devFeature branchIsolated feature worktree
Frontend/home/kirbysmashyeet/Source/BloxClips/BloxClips-frontenddev at 5ed7c746f4b70cd582897bedc94ab08c842b35dc5ed7c746f4b70cd582897bedc94ab08c842b35dcfeature/clipper-groups, tracking origin/dev/home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups
Backend/home/kirbysmashyeet/Source/BloxClips/Bloxclips-backendcodex/pv-tracker-backend-compatibility at e665292f90abb83ce8de8eaa0d8eb220025541b1aaffae37328ff25f2ae3314409751b792012f69dfeature/clipper-groups, tracking origin/dev/home/kirbysmashyeet/Source/BloxClips/Bloxclips-backend-clipper-groups

Immediately after creation, each feature worktree's HEAD exactly matched its fetched origin/dev, git status --short --branch reported no changed files, and git diff --stat origin/dev...HEAD was empty.

Preserved developer-worktree changes ​

The existing worktrees were intentionally left in place and were not used as feature bases. Each had one pre-existing modification and no other status entries:

WorktreePreserved changeFile SHA-256 at Phase 0 baseline/final verificationGit-diff object hash at Phase 0 baseline/final verification
Frontend developer worktreemodified package-lock.jsond2d39658b30b074c1cca34ad54672743bf1d7ba3de4011b4d34667f927f699575270214d2cf0bce0de83bfeeb1e3f4b6c769e0c5
Backend developer worktreemodified package-lock.json39b77011d40c5a503a46726506fd45efc8409321e021b2fb39b45f826697f8be90162b02232b5dc4b4fdf1034d639abb27a8345c

Matching file and diff hashes confirm that Phase 0 did not alter either dirty lockfile.

Architecture findings ​

Backend ​

  • Express 5 supplies the HTTP layer, Prisma 5.22 targets PostgreSQL, and Zod is the validation convention.
  • WebUser.id is the canonical clipper identity. Submission.webUserId is nullable, so legacy submissions without that relation cannot be safely attributed to a group.
  • Admin APIs use requireAuth and requireAdmin; the shared adminLimiter is applied at route mounting in src/api/index.ts.
  • Dedicated admin routers are mounted before the generic /api/admin router. Clipper Groups will follow that ordering.
  • Existing endpoints use hand-written JSON response contracts and the repository-wide { error } error shape.
  • New accountability writes belong in the generic append-only AuditLog path in src/utils/audit/log.ts, not legacy AdminAuditLog. The current writer is best-effort and bound to the global Prisma client, so it needs a compatible transaction-required extension for this domain.
  • PostgreSQL partial unique indexes have hand-authored migration precedent in the payout migrations.
  • GDPR account deletion currently hard-deletes WebUser inside a transaction. Membership intervals therefore need explicit closure and anonymization before deletion instead of cascade deletion of history.

Frontend ​

  • The frontend uses Next.js 16, React 19, Tailwind CSS 4, and route-level client state. It currently has no test framework.
  • Admin navigation is owned by app/(content-rewards)/dashboard/admin/layout.tsx.
  • Admin requests use adminFetch and safeJson from surfaces/content-rewards/lib/adminFetch.ts.
  • Canonical user presentation can reuse shared/components/common/UserAvatar.tsx.
  • Current organization places routes under app/, domain-owned UI under features/, application-wide behavior under surfaces/, and reusable components under shared/.
  • The new navigation item will appear after Users and before PV Tracker.
  • Planned routes are /dashboard/admin/clipper-groups and /dashboard/admin/clipper-groups/[groupId].

Locked domain decisions ​

Data model ​

Add two models:

  • ClipperGroup: stable CUID, trimmed mutable name, a required, immutable campaignId relation to Campaign, timestamps, nullable archivedAt, and memberships.
  • ClipperGroupMembership: stable CUID, group relation, nullable webUserId relation, joinedAt, nullable leftAt, and createdAt.

Database and lifecycle invariants:

  • New memberships reference an existing canonical WebUser.id.
  • leftAt IS NULL means active.
  • A PostgreSQL partial unique index permits at most one active membership for a (groupId, webUserId) pair while retaining closed intervals.
  • A database check requires leftAt >= joinedAt when leftAt is present.
  • Rejoining creates a new interval; it never reopens or rewrites history.
  • Groups are archived, not deleted. Group deletion is restricted. Physical deletion of a Campaign that owns any group is also restricted; the product's campaign delete is a soft delete, so archived groups always keep a real owner row.
  • A group may only be created under a campaign that is active and not soft-deleted. Existing groups keep pointing at their campaign after it ends or is soft-deleted.
  • Archiving is one-way in this foundation, atomically closes all active memberships at the archive timestamp, and makes the group read-only except for viewing.
  • Group status is independent of campaign status in one direction only: manual archive never touches the campaign, and pausing a campaign or closing its submissions never touches groups. The single coupling is the campaign's terminal transition (active true -> false), which auto-archives that campaign's active groups. Reactivating a campaign never restores them.
  • Repeated archive and remove operations are idempotent.
  • Account deletion closes active memberships and then sets historical webUserId references to null so interval history remains anonymous.
  • Index active members by group, groups by member, and group/member time intervals.
  • A user may be active in multiple groups at the same time.
  • Duplicate display names are allowed. Names are trimmed and constrained to 1-100 characters.
  • No aggregate counters, CPM fields, RPM or compensation fields, per-group campaign configuration, special identifiers, or predefined group types are stored. The only campaign data on a group is the ownership foreign key.

Admin HTTP contract ​

  • GET /api/admin/clipper-groups?archived=exclude|include|only&campaignId=<int> (campaignId optional; filtering happens in the database)
  • GET /api/admin/clipper-groups/campaign-options (registered before the dynamic /:groupId route)
  • POST /api/admin/clipper-groups with { name, campaignId }
  • GET /api/admin/clipper-groups/:groupId
  • PATCH /api/admin/clipper-groups/:groupId with { name } — no campaign reassignment
  • POST /api/admin/clipper-groups/:groupId/archive
  • GET /api/admin/clipper-groups/:groupId/member-candidates?search=...
  • POST /api/admin/clipper-groups/:groupId/members with { webUserId }
  • DELETE /api/admin/clipper-groups/:groupId/members/:webUserId

Group summary and detail responses both include the owning campaign's real id and name. Detail responses include current memberships and closed membership history. Candidate results contain canonical WebUser identity fields plus prior membership context, allowing the UI to distinguish Add from Rejoin.

Errors preserve { error } and add stable codes for validation, missing group/user, archived group, duplicate active membership, and the three ineligible-owner reasons (CAMPAIGN_NOT_FOUND, CAMPAIGN_DELETED, CAMPAIGN_INACTIVE). Duplicate active membership returns HTTP 409. Repeated removal returns HTTP 200 with removed: false.

Mutations write creation, rename, archive, add, remove, and rejoin audit records inside the same transaction as domain changes. The actor is the authenticated WebUser.id.

Scope constraints and explicit issue deviation ​

Clipper Groups remains independent of PV Tracker storage, types, APIs, JSON, components, and vocabulary. No PV/Private Team migration or integration is in scope.

The issue proposes optional systemKey support and includes an extension-point acceptance item. This implementation intentionally omits systemKey, special identifiers, predefined group types, and team-specific behavior because the task's product constraints explicitly forbid them. Stable ordinary group IDs are the only identity mechanism. This is the sole known acceptance-criteria deviation.

Also out of scope: stored aggregate metrics, CPM/RPM behavior, compensation, analytics, per-group campaign configuration, earnings or payout changes, granular RBAC, legacy submission identity reconciliation, and PV Tracker refactoring. Campaign ownership of a group is in scope; campaign configuration on a group is not.

Risks and planned controls ​

RiskPlanned control
Historical membership lossStore immutable closed intervals; never hard-delete on removal or archive.
Concurrent duplicate additionsEnforce the active-pair invariant in PostgreSQL and translate the constraint failure to HTTP 409.
Invalid membership time rangesAdd a database check in the inspected SQL migration and cover it with tests.
Archive/domain/audit partial writesKeep lifecycle and required audit writes in one Prisma transaction.
Account deletion erases historyClose active intervals and anonymize membership foreign keys explicitly before WebUser deletion.
Legacy submission misattributionDo not infer membership attribution when Submission.webUserId is null.
Overlapping-group double countingPreserve overlapping membership as valid and defer aggregation semantics.
Stale frontend stateRefetch authoritative data after mutations during integration.
Accidental PV or CPM couplingReview imports, schema, migrations, and diffs for forbidden dependencies during hardening.
Build depends on unavailable font/network resourcesRecord unrelated network failures separately; do not mask application errors.

Phase tracker ​

Phase 0 - Repository and issue reconnaissance ​

Status: READY FOR REVIEW

Completed:

  • Re-read backend issue #2 and confirmed it is open.
  • Inspected frontend PR #3 metadata as a reference only.
  • Inspected the relevant frontend and backend architecture at remote dev.
  • Recorded the pre-existing dirty developer-worktree state and lockfile fingerprints.
  • Fetched origin dev in both repositories.
  • Created both feature/clipper-groups branches and isolated worktrees at the fetched remote tips.
  • Verified clean initial feature worktrees and unchanged developer lockfiles.
  • Created this tracker. No application code, dependencies, migrations, tests, or repository documentation were changed.

Changed files:

  • /home/kirbysmashyeet/Source/BloxClips/docs/clipper-groups-plan.md (this cross-repository tracker)

Validation:

  • fetched frontend origin/dev = feature HEAD = 5ed7c746f4b70cd582897bedc94ab08c842b35dc
  • fetched backend origin/dev = feature HEAD = aaffae37328ff25f2ae3314409751b792012f69d
  • both feature branches track origin/dev
  • both feature git status --short --branch outputs contain no file changes
  • both git diff --stat origin/dev...HEAD outputs are empty
  • developer-worktree statuses still contain only their original modified package-lock.json
  • developer lockfile file and diff hashes match the Phase 0 baseline

Deviations: none. The fetched remote SHAs match the planned SHAs.

Review gate: stop here. Phase 1 must not begin before review.

Phase 1 - Frontend UI foundation ​

Status: READY FOR REVIEW

Completed, in /home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups (feature/clipper-groups, base 5ed7c746f4b70cd582897bedc94ab08c842b35dc):

  • Added a "Clipper Groups" admin nav entry between "Users" and "PV Tracker", and page-title branches for the list/detail routes.
  • Added /dashboard/admin/clipper-groups (list: archive filter tabs, create modal, table with active-member count and archived badge) and /dashboard/admin/clipper-groups/[groupId] (detail: rename, archive with confirmation, member picker with Add/Rejoin distinction, current-members table with remove, closed-membership history table).
  • Added the features/clipper-groups module: types.ts (data shapes and a ClipperGroupsApiError with stable codes matching the future backend contract), lib/clipperGroupsFixtures.ts (in-memory seed data — 4 groups covering active/empty/archived/duplicate-name, a rejoin case, and a user active in two groups at once), lib/clipperGroupsApi.ts (async functions matching the real HTTP contract's shape, backed by the fixtures, each with an artificial delay so loading/pending states are reachable), and lib/clipperGroupsUtils.ts (name validation, date formatting).
  • Built components: GroupsTable/GroupsTableSkeleton, GroupFormModal (shared create/rename), GroupDetailHeader, MemberPicker (searchable combobox modeled on CountryCombobox's interaction pattern), CurrentMembersTable, MembershipHistoryTable, GroupsToast (local toast, PV Tracker pattern).
  • Reused shared/components/common/UserAvatar.tsx and ConfirmModal.tsx as-is; no new shared components were added, matching the reconnaissance finding that none exist to extend.
  • No networking layer or browser persistence was introduced; all state is in-memory fixtures reset on page reload, as required.

Changed files:

  • Modified: app/(content-rewards)/dashboard/admin/layout.tsx, app/(content-rewards)/dashboard/layout.tsx.
  • Added: app/(content-rewards)/dashboard/admin/clipper-groups/page.tsx, app/(content-rewards)/dashboard/admin/clipper-groups/[groupId]/page.tsx, features/clipper-groups/types.ts, features/clipper-groups/lib/clipperGroupsFixtures.ts, features/clipper-groups/lib/clipperGroupsApi.ts, features/clipper-groups/lib/clipperGroupsUtils.ts, features/clipper-groups/components/GroupsToast.tsx, features/clipper-groups/components/GroupsTable.tsx, features/clipper-groups/components/GroupFormModal.tsx, features/clipper-groups/components/GroupDetailHeader.tsx, features/clipper-groups/components/MemberPicker.tsx, features/clipper-groups/components/CurrentMembersTable.tsx, features/clipper-groups/components/MembershipHistoryTable.tsx.

Validation:

  • npx tsc --noEmit: no errors.
  • npm run lint: 0 errors, 37 pre-existing warnings in unrelated files (none in any Clipper Groups file or in the two modified layout files).
  • npm run build: succeeded; both new routes (/dashboard/admin/clipper-groups, /dashboard/admin/clipper-groups/[groupId]) appear in the production route manifest.
  • npm install was required first (the feature worktree had no node_modules) and normalized package-lock.json by dropping 14 stale "peer": true flags left over from a different local npm version; this is environment noise, not a feature change, and no application dependency versions changed.
  • Manual review: this environment has no running backend and no browser automation tool available (Claude in Chrome was declined by the developer). A disposable local stub (not part of the repo, not committed) was used to satisfy the dashboard's /api/auth/me and /api/admin/check auth gate so the dev server could be smoke-tested; curl against /dashboard/admin/clipper-groups, /dashboard/admin/clipper-groups/cg_001, and a nonexistent group id all returned HTTP 200 with no server-error markers in the HTML. Full interactive/visual review (click-through of create/rename/archive/add/remove/rejoin, responsive layout at narrow width, keyboard navigation and focus states) could not be performed by the agent and needs a manual pass by a developer with a browser.

Deviations:

  • The plan's "duplicate-active add attempt showing an error toast" manual test case cannot be triggered from the MemberPicker UI directly, because the picker's candidate search already excludes users with an active membership in that group (matching how a real admin would use it). The addMember() function still defensively throws DUPLICATE_ACTIVE_MEMBERSHIP if ever called with an already-active pair (verified by code inspection), and that path is exercised by the domain invariant instead in Phase 2's backend tests, with concurrent-request coverage. No UI change is needed for this.
  • Full manual interactive/browser review (see Validation above) is deferred to a human reviewer since no browser automation tool was available in this session.

Review gate: stop here. Phase 2 must not begin before review.

Phase 2 - Backend domain foundation ​

Status: READY FOR REVIEW

Completed, in /home/kirbysmashyeet/Source/BloxClips/Bloxclips-backend-clipper-groups (feature/clipper-groups, base aaffae37328ff25f2ae3314409751b792012f69d):

  • Added ClipperGroup and ClipperGroupMembership to prisma/schema.prisma, plus the required WebUser.clipperGroupMemberships back-relation.
  • Added migration prisma/migrations/20260829120000_add_clipper_groups/. Prisma's normal migrate dev flow couldn't be used because the bloxclips_dev database user lacks shadow-database CREATE permission (P3014); instead the DDL was generated with npx prisma migrate diff --from-url $DATABASE_URL --to-schema-datamodel prisma/schema.prisma --script (excluding two unrelated pre-existing drift statements on ExternalLeaderboardPayment/ReferralLinkAlias that diff also surfaced — not part of this feature), then hand-appended the partial unique index (ClipperGroupMembership_groupId_webUserId_active_unique, active rows only) and the leftAt >= joinedAt CHECK constraint, mirroring 20260504000000_add_payout_security. Applied with npx prisma migrate deploy (works without shadow-DB permission) and verified live via pg_indexes/pg_constraint queries against bloxclips_dev.
  • Extended src/utils/audit/log.ts with writeAuditRequired(tx, entry) — an additive, non-swallowing transactional audit write sharing a buildAuditData helper with the existing best-effort audit(), whose signature and behavior are unchanged for every existing caller. Added 'CLIPPER_GROUP' to AuditCategory.
  • Added src/utils/clipperGroups/{errors.ts,domain.ts}: ClipperGroupError with stable codes (VALIDATION, GROUP_NOT_FOUND, USER_NOT_FOUND, GROUP_ARCHIVED, DUPLICATE_ACTIVE_MEMBERSHIP), and the domain functions (listGroups, getGroupDetail, createGroup, renameGroup, archiveGroup, searchMemberCandidates, addMember, removeMember). Every mutation runs its domain write and its writeAuditRequired call inside one $transaction. addMember relies on the partial unique index (catching P2002) as the actual concurrency guard, not an app-level pre-check, and distinguishes CLIPPER_GROUP_MEMBER_ADD from CLIPPER_GROUP_MEMBER_REJOIN by checking for a prior closed interval. archiveGroup and removeMember are idempotent and skip writing a redundant audit row on a no-op.
  • Added src/api/routes/adminClipperGroups.ts implementing all 8 endpoints from the locked HTTP contract, mounted in src/api/index.ts at /api/admin/clipper-groups immediately before the generic /api/admin router (same placement as pv-tracker), guarded by requireAuth+requireAdmin at the router level and the shared adminLimiter at the mount call.
  • Extended the existing GDPR DELETE /api/gdpr/delete-account transaction in src/api/routes/gdpr.ts to close active memberships and then null webUserId on all of a deleted user's membership rows, before the webUser.delete call — no new transaction, same pattern as the existing submission/payout anonymize-in-place logic.
  • Added src/utils/clipperGroups/domain.test.ts (13 tests, node:test + real bloxclips_dev, marker-prefixed rows, full wipe/cleanup) covering the full required matrix: creation/validation/duplicate-names-allowed, rename on active vs. archived, archive closure + idempotency, duplicate-active membership sequential + concurrent (Promise.allSettled), idempotent removal, rejoin producing two distinct history rows, one user active in two groups at once, the CHECK constraint rejecting an invalid interval via raw SQL, WebUser-deletion anonymization, a required audit row per mutation plus a rollback test proving a mid-transaction failure leaves neither the domain write nor the audit write, list archive filtering, and candidate-search state distinction (never/prior-closed/active).

Changed files:

  • Modified: prisma/schema.prisma, src/api/index.ts, src/api/routes/gdpr.ts, src/utils/audit/log.ts.
  • Added: prisma/migrations/20260829120000_add_clipper_groups/migration.sql, src/utils/clipperGroups/errors.ts, src/utils/clipperGroups/domain.ts, src/utils/clipperGroups/domain.test.ts, src/api/routes/adminClipperGroups.ts.

Validation:

  • npx prisma validate: schema valid.
  • npx prisma migrate status: "Database schema is up to date!" against bloxclips_dev, no drift.
  • npx prisma generate: succeeded.
  • npm run build (tsc): no errors.
  • node --require ts-node/register --test src/utils/clipperGroups/domain.test.ts: 13/13 passing against bloxclips_dev; a follow-up query confirmed zero marker-prefixed rows remained afterward. Note: this repo's documented invocation (node --import ts-node/register --test <file>) fails on this machine's Node 22.19 with "Cannot use import statement outside a module" — confirmed to be a pre-existing environment issue by reproducing the same failure against the existing referrals/policy.test.ts, not something introduced by this change. --require (instead of --import) works correctly and was used instead.
  • Existing tests: not re-run as part of this phase (no changes were made that touch referrals/payments code paths beyond the additive, behavior-preserving audit() refactor into buildAuditData, which npm run build type-checks against all existing callers).
  • No backend lint script exists — skipped, consistent with the rest of the repo.
  • npm install normalized package-lock.json by dropping 4 stale "peer": true flags (same environment noise seen in Phase 1's frontend worktree) — not a feature change, no dependency versions changed.
  • .env was copied from the main developer worktree (/home/kirbysmashyeet/Source/BloxClips/Bloxclips-backend/.env) per explicit instruction; it is gitignored and was not committed.

Deviations:

  • npx prisma migrate dev could not be used to generate/apply the migration because the bloxclips_dev database user lacks shadow-database CREATE permission. Used npx prisma migrate diff to generate the DDL and npx prisma migrate deploy to apply it instead (see above) — the resulting schema, indexes, and constraints were verified directly against the live database and are identical to what migrate dev would have produced.
  • The documented test-run command in this repo's testing convention (--import ts-node/register) does not work on this environment's Node version; --require ts-node/register was used instead and should be noted for other engineers hitting the same issue.
  • Manual HTTP-level exercise of the 8 endpoints via a running API instance was not performed (not required for Phase 2 sign-off per the master plan, since frontend/backend integration is Phase 3) — the domain module, which every route thinly wraps, is fully covered by the test suite above.

Review gate: stop here. Phase 3 must not begin before review.

Phase 3 - Frontend/backend integration ​

Status: READY FOR REVIEW

Completed, in /home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups (feature/clipper-groups, on top of the Phase 1 commit 223098a):

  • Added .env.local (copied from the main frontend developer worktree, same precedent as Phase 2's backend .env; gitignored, not committed) so NEXT_PUBLIC_API_URL=http://localhost:3001 is available for the real API client.
  • Rewrote features/clipper-groups/lib/clipperGroupsApi.ts to call the live backend via adminFetch/safeJson (@/surfaces/content-rewards/lib/adminFetch), modeled on features/pv-tracker/lib/pvTrackerApi.ts's xJson<T> wrapper pattern. Every exported function keeps its exact Phase 1 name and signature, so no page or component changed. A toApiError helper translates the backend's 5 error codes down to the frontend's already locked 4-code ClipperGroupsApiError contract (GROUP_NOT_FOUND/USER_NOT_FOUND -> NOT_FOUND, GROUP_ARCHIVED -> ARCHIVED, VALIDATION/DUPLICATE_ACTIVE_MEMBERSHIP pass through), and getGroup catches a translated NOT_FOUND to return null, preserving the detail page's existing not-found branch. Client-side validateGroupName pre-checks in createGroup/renameGroup were kept for instant feedback, in addition to the server's own validation.
  • Deleted features/clipper-groups/lib/clipperGroupsFixtures.ts (all Phase 1 mock data removed); confirmed zero remaining references anywhere in features/ or app/.
  • Added handle: string | null to WebUserIdentity and MembershipInterval in features/clipper-groups/types.ts — the real backend's GroupMember/MembershipInterval/MemberCandidate payloads include this field and the Phase 1 types (written against a plan, not the real contract) had omitted it. ClipperGroup already matched ClipperGroupSummary field-for-field; no other type changes were needed.

Changed files:

  • Modified: features/clipper-groups/types.ts, features/clipper-groups/lib/clipperGroupsApi.ts.
  • Added: .env.local (gitignored, not committed).
  • Deleted: features/clipper-groups/lib/clipperGroupsFixtures.ts.

Validation:

  • grep -rn "clipperGroupsFixtures" features/ app/: zero matches.
  • npm run build: succeeded, full TypeScript check passed, both Clipper Groups routes present in the production route manifest.
  • Real end-to-end network smoke test against the live bloxclips_dev database: the backend's Express app (imported directly from src/api/index.ts, bypassing src/api/server.ts's separate Discord client and startApiServer()'s payment/PV-sync schedulers so the test process had no side effects on the already-running main-worktree backend or its live Discord bot session) was started on a scratch port with a temporary marker Discord ID appended to ADMIN_DISCORD_IDS for that process only. A signed JWT for a marker-prefixed admin WebUser was set as the auth_token cookie and used to drive all 8 endpoints with marker-prefixed (__clippergroups_it__smoke) data: create, list, get-detail, rename, search-candidates, add-member, duplicate-add (409 DUPLICATE_ACTIVE_MEMBERSHIP), remove, idempotent-remove, candidate search reflecting priorMembership after removal, archive, rename-on-archived (409 GROUP_ARCHIVED), idempotent re-archive, not-found lookup (404 GROUP_NOT_FOUND), and blank-name validation (400) — all passed. All marker rows were removed afterward and independently verified at zero (groups/users/memberships all 0).
  • Browser-based click-through of the actual Next.js pages against the live backend was not performed — no browser automation tool is available in this environment (declined earlier in this engagement). This is the same gap noted in Phase 1; a developer should click through /dashboard/admin/clipper-groups themselves (both dev servers running, logged in as an admin) before final sign-off.
  • npm install/lockfile churn: none this phase (no new dependencies).

Deviations:

  • Added handle: string | null to the frontend's WebUserIdentity and MembershipInterval types (see above) — a correction to a Phase 1 type gap discovered while integrating against the real contract, not a scope change.
  • Manual browser click-through is deferred to a human reviewer, same as Phase 1 (see Validation above).

Review gate: stop here. Phase 4 must not begin before review.

Phase 4 - Hardening ​

Status: READY FOR REVIEW

Reviewed both repositories' full diffs against their recorded origin/dev bases (frontend: 15 files, 1252 insertions / 1 deletion committed in Phase 1, plus Phase 3's 3-file, uncommitted rewrite; backend: 9 files, 1027 insertions / 15 deletions, committed in Phase 2) against each checklist item:

  • Race handling: the duplicate-active-membership race is guarded by the partial unique index and exercised by both a concurrent (Promise.allSettled) domain test (Phase 2) and a real sequential-HTTP 409 check (Phase 3's smoke test) — confirmed sound. New finding: a narrow, unguarded race exists between archiveGroup and addMember — addMember reads group.archivedAt without taking a row lock, so a concurrent archiveGroup commit between that read and addMember's insert could leave one membership active in an otherwise-archived group. Both are human-admin-triggered, low-frequency actions, and the worst case is one dangling active interval (not data corruption or a crash) — so this is recorded as an accepted, low-likelihood residual risk rather than fixed outright, pending a decision on whether row-level locking (e.g. SELECT ... FOR UPDATE on the group row) is worth the added complexity for this scenario.
  • Authorization: all 8 admin endpoints sit behind a single router.use(requireAuth, requireAdmin, ...); requireAdmin checks Discord ID / verified admin email / linked Google admin email against ADMIN_DISCORD_IDS/ADMIN_EMAILS — unchanged from the existing convention, verified again via the Phase 3 smoke test's real JWT/cookie flow.
  • Audit atomicity: every mutating domain function's writeAuditRequired call lives inside the same $transaction as its domain write; the rollback test (Phase 2, still passing) proves a forced failure leaves neither write behind.
  • Migration safety: npx prisma migrate status reports "Database schema is up to date!" with no drift; schema, indexes, and constraints re-verified unchanged since Phase 2.
  • GDPR retention: the added gdpr.ts block closes active memberships before nulling webUserId on all of a deleted user's membership rows (active-just-closed and previously-closed alike), inside the existing deletion transaction, before webUser.delete — matches the locked "anonymize, never delete membership history" invariant; no new transaction was introduced.
  • Stale frontend state: every mutation path (create, rename, archive, add member, remove member) on both the list and detail pages calls refresh()/re-fetches authoritative data after the network call resolves, before showing its success toast.
  • Mock/debug leftovers: grepped both repositories' Clipper Groups files for console.log/debugger/TODO/FIXME and fixture-era ids (cg_00*, cgm_00*, wu_00*) — zero matches. The Phase 1 fixtures file itself was deleted in Phase 3.
  • Accidental PV/CPM coupling: grepped both repositories' Clipper Groups files for cpm/pv-tracker/pvtracker — zero matches; the feature remains fully independent of PV Tracker, as required.

Fixed during this review, in /home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups (uncommitted, layered on top of Phase 3):

  • features/clipper-groups/components/MemberPicker.tsx: handleAdd called onAdd(candidate) without a catch, so once Phase 3 made onAdd (handleAddMember in the detail page) hit a real network call and deliberately rethrow after showing its own error toast, every failed add-member produced an unhandled promise rejection in the browser console. Added a no-op catch — the toast is already the user-facing signal, and the picker still stays open on failure exactly as before (setOpen(false) is still only reached on the success path).

Changed files (this phase):

  • Modified: features/clipper-groups/components/MemberPicker.tsx.
  • No backend files changed.

Validation:

  • Backend: npm run build (tsc) — no errors. npx prisma migrate status — up to date, no drift. Full domain suite re-run: node --require ts-node/register --test src/utils/clipperGroups/domain.test.ts — 13/13 passing against bloxclips_dev.
  • Frontend: npm run build — succeeded, full TypeScript check passed. npm run lint — 0 errors, 37 pre-existing warnings, none in any Clipper Groups file (same count/files as Phase 1's baseline).
  • No dependency or lockfile changes this phase.

Deviations: none beyond the one-line MemberPicker fix documented above.

Review gate: stop here. No merge, push, or commit is authorized without explicit request; the Phase 3 and Phase 4 frontend changes remain uncommitted pending review.

Phase 5 - Campaign-scoped Clipper Groups (follow-up phase) ​

Status: VALIDATED — PRs OPEN

Codex orchestrated the implementation of this phase with read/search/edit tools only and ran no commands. Claude (a separate session, taking over after Codex hit usage limits) inspected the resulting worktrees, confirmed the group/campaign-archive concurrency fix was already fully implemented and tested (see below), ran the full validation matrix, committed both worktrees, and opened both PRs.

Worktree metadata ​

RepositoryIsolated worktree for this phase
Frontend/home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups-campaign-scope
Backend/home/kirbysmashyeet/Source/BloxClips/Bloxclips-backend-clipper-groups-campaign-scope
Tracker/home/kirbysmashyeet/Source/BloxClips/docs/clipper-groups-plan.md

The original developer worktrees and the earlier -clipper-groups feature worktrees were not touched. No package-lock.json was edited in either repository. Nothing was committed or pushed.

Note for reviewers: the frontend worktree is based on a newer dev than Phases 1-4 recorded. Clipper Groups UI now lives under surfaces/content-rewards/screens/admin/clipper-groups/ with its data layer in features/admin/clipper-groups/, not the features/clipper-groups/ paths listed in Phase 1. That reorganization predates this phase.

Behavior ​

Data model and migration:

  • ClipperGroup.campaignId is a required Int relation to Campaign, with a Campaign.clipperGroups back-relation, onDelete: Restrict / onUpdate: Cascade, and a new @@index([campaignId, archivedAt]) serving both the list filter and the terminal auto-archive sweep.
  • One-time authorized dev data cleanup. ClipperGroup pre-dates the column and there is no correct owner to backfill, so migration 20260830120000_clipper_group_campaign_scope deletes, in FK order, every ClipperGroupMembership row, every ClipperGroup row, and every CLIPPER_GROUP-category AuditLog row before adding the required relation. This is a development database and the feature is unreleased. Nothing outside Clipper Groups is read or written. The exception applies to this one migration only; from here forward group and membership history is preserved permanently — groups are archived rather than deleted, memberships are closed rather than removed, and audit rows stay append-only.

Create eligibility and concurrency:

  • A group can only be created under a campaign that exists, is not soft-deleted, and is active. The three failures return distinct stable codes (CAMPAIGN_NOT_FOUND 404, CAMPAIGN_DELETED 409, CAMPAIGN_INACTIVE 409), surfaced as distinct messages in the create modal.
  • lockCampaignForUpdate takes SELECT ... FOR UPDATE on the campaign row. Both createGroup and every terminal transition take that lock first, so the two serialize. Either the create commits first and the terminal sweep then archives the new group, or the terminal transition commits first and the create is rejected with CAMPAIGN_INACTIVE. An active group can never be left under an inactive campaign.
  • lockGroupForUpdate does the same one level down, on the ClipperGroup row. It is the second serialization point, covering mutations of an existing group rather than creation of a new one: addMember, renameGroup, and manual archiveGroup each take it before reading archivedAt and hold it through their writes, and the terminal sweep takes it per group before timestamping that group. Without it, addMember could read archivedAt = null from its snapshot, a terminal transaction could archive the group and close its memberships, and addMember could then insert a fresh leftAt = null membership under an already-archived group. Both serialized outcomes are now correct: the add commits first and the sweep closes its brand-new interval into history, or the sweep commits first and the add is rejected with GROUP_ARCHIVED. Renames of an archived group are rejected the same way.
  • Lock ordering is one-directional — Campaign, then ClipperGroup. Group mutations need no campaign row and take only the group lock; terminal transitions take the campaign lock first and only then touch group rows. No path locks a group before a campaign, so the two cannot deadlock.
  • The sweep's archive timestamp is taken per group after that group's row lock is held, so a membership inserted by the addMember that just released the lock always satisfies the leftAt >= joinedAt CHECK. A group that was manually archived while the sweep waited on its lock is left as the winner left it and is not counted or re-audited.

Campaign terminal transitions:

  • "Terminal" means Campaign.active goes true -> false, and nothing else. paused and acceptingSubmissions = false are non-terminal and touch no group. Reactivation never restores groups.
  • All three discovered paths that set active = false now route through updateCampaignWithGroupLifecycle, which locks the campaign row, applies the update, and — only on a true -> false transition — archives that campaign's active groups and closes their memberships in the same transaction:
    • src/scheduler.ts deadline sweep (SYSTEM actor, trigger CAMPAIGN_DEADLINE),
    • PUT /api/admin/campaigns/:id (ADMIN actor, trigger CAMPAIGN_ADMIN_UPDATE; non-terminal updates pass straight through),
    • DELETE /api/admin/campaigns/:id soft delete (ADMIN actor, trigger CAMPAIGN_SOFT_DELETE).
  • Audit behavior is unchanged in kind: one CLIPPER_GROUP_ARCHIVE row per archived group, written with writeAuditRequired inside the same transaction, ADMIN for admin transitions and SYSTEM for the scheduler. Metadata carries trigger (MANUAL for a hand archive) and campaignId.
  • src/utils/campaignBudget.ts was inspected and left alone: it only writes acceptingSubmissions / viewsFrozen, which are non-terminal.

API:

  • GET / accepts the existing archived plus optional integer campaignId and filters in the database. An unknown campaignId is an empty result, not an error.
  • GET /campaign-options is registered before GET /:groupId and returns real ids and names, active / isDeleted, and eligibleForNewGroups. It lists every non-deleted campaign, active or ended, so the campaign filter and a campaign detail deep-link stay visibly selected even for an ended campaign that owns no groups; it additionally lists a soft-deleted campaign only when a historical group still references it, so a deleted owner always renders with a real name. eligibleForNewGroups stays true only for active, non-deleted campaigns, so create eligibility is unchanged by this listing.
  • POST / requires { name, campaignId }. PATCH /:groupId still accepts only { name }; a campaignId in a PATCH body is ignored, not honoured.
  • Group summary and detail payloads both carry campaignId and campaignName.

Frontend:

  • The Clipper Groups page remains the primary UX. It gains a real Campaign filter (default All Campaigns) whose selection is synced to the actual Next route query ?campaignId=<id> via router.replace, and requests the backend with the real integer id. Every table row shows its owning campaign.
  • The create modal requires a Campaign select. It prefills from the current filter only when that campaign is eligible, and is blank otherwise. With no eligible campaigns, "New group" is disabled with an explanatory title and an inline note, and the modal's select and submit are disabled too. That note distinguishes "no active campaign exists" from "campaign options failed to load", so a network failure is not misreported as an empty campaign list.
  • Group detail shows the campaign read-only; there is no reassignment control anywhere.
  • List-page copy changed from "independent of campaigns" to "Every group belongs to one campaign, chosen when the group is created."
  • The campaign detail header gains a single View Clipper Groups link to /dashboard/admin/clipper-groups?campaignId=<real id>. No group create, edit, or member management was added to any Campaign page.

Changed files:

  • Backend, modified: prisma/schema.prisma, src/scheduler.ts, src/api/routes/admin.ts, src/api/routes/adminClipperGroups.ts, src/utils/clipperGroups/domain.ts, src/utils/clipperGroups/errors.ts, src/utils/clipperGroups/domain.test.ts.
  • Backend, added: prisma/migrations/20260830120000_clipper_group_campaign_scope/migration.sql, src/utils/clipperGroups/campaignLifecycle.ts.
  • Frontend, modified: features/admin/clipper-groups/types.ts, features/admin/clipper-groups/api/adminClipperGroups.ts, features/admin/clipper-groups/lib/adminClipperGroups.ts, surfaces/content-rewards/screens/admin/clipper-groups/AdminClipperGroupsScreen.tsx, surfaces/content-rewards/screens/admin/clipper-groups/AdminClipperGroupDetailScreen.tsx, surfaces/content-rewards/screens/admin/clipper-groups/components/GroupFormModal.tsx, surfaces/content-rewards/screens/admin/clipper-groups/components/GroupsTable.tsx, surfaces/content-rewards/screens/admin/clipper-groups/components/GroupDetailHeader.tsx, surfaces/content-rewards/screens/admin/campaign-detail/components/AdminCampaignDetailHeader.tsx.
  • Tracker, modified: docs/clipper-groups-plan.md.

Concurrency fix status at handoff (Claude session, 2026-08-30) ​

Codex's handoff flagged a race between addMember and campaign auto-archive and said Claude was "in the process of fixing this by serializing group mutations on the group row." On inspection, that fix was already complete in the uncommitted worktree, not partial:

  • lockCampaignForUpdate (SELECT ... FOR UPDATE on Campaign) serializes createGroup against every terminal transition.
  • lockGroupForUpdate (SELECT ... FOR UPDATE on ClipperGroup) serializes addMember, renameGroup, and manual archiveGroup against the terminal sweep (archiveActiveGroupsForCampaign), which takes the same lock per group before timestamping it.
  • Lock order is one-directional (Campaign, then ClipperGroup; group mutations take no campaign lock), so the two lock kinds cannot deadlock.
  • removeMember intentionally takes no lock — it only closes an already-open interval and can never create a new active membership under an archived group, so it sits outside the invariant this fix protects.
  • The regression test (domain.test.ts, "adding a member and ending the owning campaign serialize on the group row lock") races addMember against updateCampaignWithGroupLifecycle with Promise.allSettled and asserts the invariant holds under both possible interleavings: either the add wins and its interval is closed into history at the archive timestamp, or the terminal transition wins and the add is rejected with GROUP_ARCHIVED. Either way: campaign inactive, group archived, no membership left with leftAt null, and the archived group rejects a subsequent rename.
  • A sibling test races createGroup against a terminal transition on the campaign lock, with the same both-interleavings assertion structure.

No additional code changes were needed for the concurrency fix itself. Claude verified it end-to-end (see Validation below) rather than re-implementing it.

Validation ​

Run by Claude in this session, against the same bloxclips_dev Neon database used by earlier phases.

CheckCommandResult
Prisma schemanpx prisma validatePass — schema valid
Migration appliesnpx prisma migrate deploy then npx prisma migrate statusPass — 20260830120000_clipper_group_campaign_scope applied; "Database schema is up to date!"
Prisma clientnpx prisma generatePass — generated cleanly under Node 22.19.0 (see Node/Prisma note below)
Backend typechecknpm run build (tsc)Pass — no errors
Backend domain suitenode --require ts-node/register --test src/utils/clipperGroups/domain.test.tsPass — 26/26 (13 pre-existing + 13 campaign-scope, including both concurrency race tests); ~4 min wall time, dominated by per-test Neon network round trips. Verified zero __clippergroups_it__-marker rows remained afterward in ClipperGroup, Campaign, and WebUser.
Frontend typechecknpx tsc --noEmitPass — no errors
Frontend lintnpm run lintPass — 0 errors, 15 pre-existing warnings, none in any Clipper Groups file or in AdminCampaignDetailHeader.tsx
Frontend buildnpm run build (next build --webpack)Pass — both Clipper Groups routes present in the route manifest. The plain npm run build alias defaults to Turbopack, which fails in this worktree specifically (Symlink [project]/node_modules is invalid, it points out of the filesystem root) because node_modules here is a symlink into the main frontend worktree rather than a real install — a local disk-space workaround from earlier setup (root filesystem was at 99% full, ~2.5 GB free), not a code or feature issue. --webpack resolves the symlink correctly and the build is otherwise clean; a real npm install in this worktree was not attempted given the disk headroom.
Manual click-throughlist filter + route query, create modal states, detail read-only campaign, campaign-detail linkNot performed — no browser automation tool was available in this session either; still needs a human pass, as in every earlier phase.

Node/Prisma environment note: Codex's handoff reported that "this machine's Prisma 5.22 generator silently exits under Node 22 before emitting a client." This was re-checked and did not reproduce: this machine has Node v22.19.0 active (via nvm; only 22.x versions are installed locally, no Node 20 available or installed) and npx prisma generate completed successfully in under a second, producing a working client (verified node_modules/.prisma/client/index.d.ts contains the new campaignId field). npx prisma validate, migrate deploy, and the full domain test suite (which depends on the generated client) all subsequently passed against the live database, which would not be possible with a stale/missing client. No Node version switch was needed or performed. It's possible the earlier failure was specific to a since-resolved state of that worktree (e.g. a partial node_modules before npm install completed); it is not a currently-reproducible blocker.

Reminder from earlier phases, unchanged: this repo's documented test invocation (--import ts-node/register) fails on Node 22 with "Cannot use import statement outside a module"; --require ts-node/register is the working form. The bloxclips_dev user lacks shadow-database CREATE permission, so migrate deploy (not migrate dev) is the working apply path.

Deviations and open concerns ​

  • The list page still swaps the whole view for a skeleton while reloading, so the campaign select briefly disappears when it changes. This matches the page's pre-existing behavior for the archive tabs and was left alone to keep the diff narrow; worth a follow-up if it reads badly in the browser.
  • Membership leftAt is a JS new Date() while joinedAt comes from the database default, so a large app/DB clock skew could in principle trip the leftAt >= joinedAt CHECK. This is pre-existing to the manual archive path and was not changed here.
  • Campaign-options results are fetched once per page mount rather than on each filter change; a campaign that ends while the page is open stays listed until reload. The server still rejects the create, so the invariant holds.
  • No browser click-through was possible; as in Phases 1, 3, and 4, a human reviewer should exercise the pages.
  • The frontend feature worktree's node_modules is a symlink into the main frontend worktree (a prior disk-space workaround), which breaks the Turbopack-default npm run build there specifically; use next build --webpack in that worktree, or run a real npm install if disk space allows. Not a code issue; noted above under Validation.

Review gate: implementation validated and committed. Backend PR: BloxClips/Bloxclips-backend#5. Frontend PR: BloxClips/BloxClips-frontend#6. Neither PR has been merged; issue #3 (compensation follow-up) remains deliberately unimplemented and open.

Backend test matrix for later phases ​

  • creation, trimming, empty/overlong validation, and duplicate display names
  • stable-ID rename and archived-group mutation rejection
  • archive closure and idempotent archive
  • duplicate active membership, including concurrent attempts
  • idempotent removal and immutable closed history
  • rejoin with a distinct interval
  • simultaneous membership in multiple groups
  • invalid interval database rejection
  • WebUser deletion closure/anonymization preservation
  • required audit creation and transaction rollback on audit failure
  • archived list filters and group detail history
  • canonical candidate search and prior-membership context
  • authentication, admin authorization, and stable error contracts

Campaign-scope additions (Phase 5):

  • exactly one immutable campaign owner per group, echoed on the summary
  • create eligibility: missing, soft-deleted, and inactive campaigns rejected with distinct codes, leaving no partial row behind
  • campaign-options listing every non-deleted campaign (an inactive campaign with no groups is present but ineligible), omitting an unreferenced soft-deleted campaign, and including a soft-deleted campaign that a historical group still references, flagged ineligible
  • list filtering by campaign, combined with the archive filter, and an unknown campaign returning an empty result
  • summary, detail, and rename payloads all carrying campaign id and name
  • manual archive leaving the campaign untouched and sibling groups active
  • terminal auto-archive on deadline (SYSTEM), admin end, and soft delete (ADMIN), preserving every membership interval
  • pause and acceptingSubmissions = false being non-terminal
  • reactivation not restoring archived groups or memberships
  • concurrent create vs. terminal transition serializing on the campaign row lock
  • concurrent addMember vs. terminal transition serializing on the group row lock, accepting either valid outcome — the add wins and its interval is closed into history at the archive timestamp, or the terminal transition wins and the add is rejected with GROUP_ARCHIVED — and asserting the invariants that hold in both: campaign inactive, group archived, currentMembers empty, no membership left with leftAt null, and the archived group read-only to a subsequent rename
  • physical deletion of a campaign that owns a group being restricted

Deferred decisions ​

The following remain deliberately unimplemented: CPM precedence, rate shape, rate timing, analytics attribution timing, overlapping-group aggregation, reactivation, granular RBAC, legacy submission identity reconciliation, and all PV Tracker integration.

No merge, push, or commit is authorized by this tracker. If commits are later requested, their subjects will follow Conventional Commits and explanatory bodies will be added when useful.