Appearance
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.
| Repository | Existing developer worktree | Existing branch and HEAD | Fetched origin/dev | Feature branch | Isolated feature worktree |
|---|---|---|---|---|---|
| Frontend | /home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend | dev at 5ed7c746f4b70cd582897bedc94ab08c842b35dc | 5ed7c746f4b70cd582897bedc94ab08c842b35dc | feature/clipper-groups, tracking origin/dev | /home/kirbysmashyeet/Source/BloxClips/BloxClips-frontend-clipper-groups |
| Backend | /home/kirbysmashyeet/Source/BloxClips/Bloxclips-backend | codex/pv-tracker-backend-compatibility at e665292f90abb83ce8de8eaa0d8eb220025541b1 | aaffae37328ff25f2ae3314409751b792012f69d | feature/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:
| Worktree | Preserved change | File SHA-256 at Phase 0 baseline/final verification | Git-diff object hash at Phase 0 baseline/final verification |
|---|---|---|---|
| Frontend developer worktree | modified package-lock.json | d2d39658b30b074c1cca34ad54672743bf1d7ba3de4011b4d34667f927f69957 | 5270214d2cf0bce0de83bfeeb1e3f4b6c769e0c5 |
| Backend developer worktree | modified package-lock.json | 39b77011d40c5a503a46726506fd45efc8409321e021b2fb39b45f826697f8be | 90162b02232b5dc4b4fdf1034d639abb27a8345c |
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.idis the canonical clipper identity.Submission.webUserIdis nullable, so legacy submissions without that relation cannot be safely attributed to a group.- Admin APIs use
requireAuthandrequireAdmin; the sharedadminLimiteris applied at route mounting insrc/api/index.ts. - Dedicated admin routers are mounted before the generic
/api/adminrouter. 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
AuditLogpath insrc/utils/audit/log.ts, not legacyAdminAuditLog. 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
WebUserinside 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
adminFetchandsafeJsonfromsurfaces/content-rewards/lib/adminFetch.ts. - Canonical user presentation can reuse
shared/components/common/UserAvatar.tsx. - Current organization places routes under
app/, domain-owned UI underfeatures/, application-wide behavior undersurfaces/, and reusable components undershared/. - The new navigation item will appear after
Usersand beforePV Tracker. - Planned routes are
/dashboard/admin/clipper-groupsand/dashboard/admin/clipper-groups/[groupId].
Locked domain decisions
Data model
Add two models:
ClipperGroup: stable CUID, trimmed mutable name, a required, immutablecampaignIdrelation toCampaign, timestamps, nullablearchivedAt, and memberships.ClipperGroupMembership: stable CUID, group relation, nullablewebUserIdrelation,joinedAt, nullableleftAt, andcreatedAt.
Database and lifecycle invariants:
- New memberships reference an existing canonical
WebUser.id. leftAt IS NULLmeans 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 >= joinedAtwhenleftAtis 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
Campaignthat 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 (
activetrue -> 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
webUserIdreferences 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>(campaignIdoptional; filtering happens in the database)GET /api/admin/clipper-groups/campaign-options(registered before the dynamic/:groupIdroute)POST /api/admin/clipper-groupswith{ name, campaignId }GET /api/admin/clipper-groups/:groupIdPATCH /api/admin/clipper-groups/:groupIdwith{ name }— no campaign reassignmentPOST /api/admin/clipper-groups/:groupId/archiveGET /api/admin/clipper-groups/:groupId/member-candidates?search=...POST /api/admin/clipper-groups/:groupId/memberswith{ 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
| Risk | Planned control |
|---|---|
| Historical membership loss | Store immutable closed intervals; never hard-delete on removal or archive. |
| Concurrent duplicate additions | Enforce the active-pair invariant in PostgreSQL and translate the constraint failure to HTTP 409. |
| Invalid membership time ranges | Add a database check in the inspected SQL migration and cover it with tests. |
| Archive/domain/audit partial writes | Keep lifecycle and required audit writes in one Prisma transaction. |
| Account deletion erases history | Close active intervals and anonymize membership foreign keys explicitly before WebUser deletion. |
| Legacy submission misattribution | Do not infer membership attribution when Submission.webUserId is null. |
| Overlapping-group double counting | Preserve overlapping membership as valid and defer aggregation semantics. |
| Stale frontend state | Refetch authoritative data after mutations during integration. |
| Accidental PV or CPM coupling | Review imports, schema, migrations, and diffs for forbidden dependencies during hardening. |
| Build depends on unavailable font/network resources | Record 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 devin both repositories. - Created both
feature/clipper-groupsbranches 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= featureHEAD=5ed7c746f4b70cd582897bedc94ab08c842b35dc - fetched backend
origin/dev= featureHEAD=aaffae37328ff25f2ae3314409751b792012f69d - both feature branches track
origin/dev - both feature
git status --short --branchoutputs contain no file changes - both
git diff --stat origin/dev...HEADoutputs 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-groupsmodule:types.ts(data shapes and aClipperGroupsApiErrorwith 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), andlib/clipperGroupsUtils.ts(name validation, date formatting). - Built components:
GroupsTable/GroupsTableSkeleton,GroupFormModal(shared create/rename),GroupDetailHeader,MemberPicker(searchable combobox modeled onCountryCombobox's interaction pattern),CurrentMembersTable,MembershipHistoryTable,GroupsToast(local toast, PV Tracker pattern). - Reused
shared/components/common/UserAvatar.tsxandConfirmModal.tsxas-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 installwas required first (the feature worktree had nonode_modules) and normalizedpackage-lock.jsonby dropping 14 stale"peer": trueflags 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/meand/api/admin/checkauth gate so the dev server could be smoke-tested;curlagainst/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
MemberPickerUI 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). TheaddMember()function still defensively throwsDUPLICATE_ACTIVE_MEMBERSHIPif 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
ClipperGroupandClipperGroupMembershiptoprisma/schema.prisma, plus the requiredWebUser.clipperGroupMembershipsback-relation. - Added migration
prisma/migrations/20260829120000_add_clipper_groups/. Prisma's normalmigrate devflow couldn't be used because thebloxclips_devdatabase user lacks shadow-database CREATE permission (P3014); instead the DDL was generated withnpx prisma migrate diff --from-url $DATABASE_URL --to-schema-datamodel prisma/schema.prisma --script(excluding two unrelated pre-existing drift statements onExternalLeaderboardPayment/ReferralLinkAliasthat diff also surfaced — not part of this feature), then hand-appended the partial unique index (ClipperGroupMembership_groupId_webUserId_active_unique, active rows only) and theleftAt >= joinedAtCHECK constraint, mirroring20260504000000_add_payout_security. Applied withnpx prisma migrate deploy(works without shadow-DB permission) and verified live viapg_indexes/pg_constraintqueries againstbloxclips_dev. - Extended
src/utils/audit/log.tswithwriteAuditRequired(tx, entry)— an additive, non-swallowing transactional audit write sharing abuildAuditDatahelper with the existing best-effortaudit(), whose signature and behavior are unchanged for every existing caller. Added'CLIPPER_GROUP'toAuditCategory. - Added
src/utils/clipperGroups/{errors.ts,domain.ts}:ClipperGroupErrorwith 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 itswriteAuditRequiredcall inside one$transaction.addMemberrelies on the partial unique index (catchingP2002) as the actual concurrency guard, not an app-level pre-check, and distinguishesCLIPPER_GROUP_MEMBER_ADDfromCLIPPER_GROUP_MEMBER_REJOINby checking for a prior closed interval.archiveGroupandremoveMemberare idempotent and skip writing a redundant audit row on a no-op. - Added
src/api/routes/adminClipperGroups.tsimplementing all 8 endpoints from the locked HTTP contract, mounted insrc/api/index.tsat/api/admin/clipper-groupsimmediately before the generic/api/adminrouter (same placement aspv-tracker), guarded byrequireAuth+requireAdminat the router level and the sharedadminLimiterat the mount call. - Extended the existing GDPR
DELETE /api/gdpr/delete-accounttransaction insrc/api/routes/gdpr.tsto close active memberships and then nullwebUserIdon all of a deleted user's membership rows, before thewebUser.deletecall — 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+ realbloxclips_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!" againstbloxclips_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 againstbloxclips_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 existingreferrals/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 intobuildAuditData, whichnpm run buildtype-checks against all existing callers). - No backend lint script exists — skipped, consistent with the rest of the repo.
npm installnormalizedpackage-lock.jsonby dropping 4 stale"peer": trueflags (same environment noise seen in Phase 1's frontend worktree) — not a feature change, no dependency versions changed..envwas 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 devcould not be used to generate/apply the migration because thebloxclips_devdatabase user lacks shadow-database CREATE permission. Usednpx prisma migrate diffto generate the DDL andnpx prisma migrate deployto apply it instead (see above) — the resulting schema, indexes, and constraints were verified directly against the live database and are identical to whatmigrate devwould 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/registerwas 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) soNEXT_PUBLIC_API_URL=http://localhost:3001is available for the real API client. - Rewrote
features/clipper-groups/lib/clipperGroupsApi.tsto call the live backend viaadminFetch/safeJson(@/surfaces/content-rewards/lib/adminFetch), modeled onfeatures/pv-tracker/lib/pvTrackerApi.ts'sxJson<T>wrapper pattern. Every exported function keeps its exact Phase 1 name and signature, so no page or component changed. AtoApiErrorhelper translates the backend's 5 error codes down to the frontend's already locked 4-codeClipperGroupsApiErrorcontract (GROUP_NOT_FOUND/USER_NOT_FOUND->NOT_FOUND,GROUP_ARCHIVED->ARCHIVED,VALIDATION/DUPLICATE_ACTIVE_MEMBERSHIPpass through), andgetGroupcatches a translatedNOT_FOUNDto returnnull, preserving the detail page's existing not-found branch. Client-sidevalidateGroupNamepre-checks increateGroup/renameGroupwere 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 infeatures/orapp/. - Added
handle: string | nulltoWebUserIdentityandMembershipIntervalinfeatures/clipper-groups/types.ts— the real backend'sGroupMember/MembershipInterval/MemberCandidatepayloads include this field and the Phase 1 types (written against a plan, not the real contract) had omitted it.ClipperGroupalready matchedClipperGroupSummaryfield-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_devdatabase: the backend's Expressapp(imported directly fromsrc/api/index.ts, bypassingsrc/api/server.ts's separate Discord client andstartApiServer()'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 toADMIN_DISCORD_IDSfor that process only. A signed JWT for a marker-prefixed adminWebUserwas set as theauth_tokencookie 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 (409DUPLICATE_ACTIVE_MEMBERSHIP), remove, idempotent-remove, candidate search reflectingpriorMembershipafter removal, archive, rename-on-archived (409GROUP_ARCHIVED), idempotent re-archive, not-found lookup (404GROUP_NOT_FOUND), and blank-name validation (400) — all passed. All marker rows were removed afterward and independently verified at zero (groups/users/membershipsall0). - 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-groupsthemselves (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 | nullto the frontend'sWebUserIdentityandMembershipIntervaltypes (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 betweenarchiveGroupandaddMember—addMemberreadsgroup.archivedAtwithout taking a row lock, so a concurrentarchiveGroupcommit between that read andaddMember'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 UPDATEon the group row) is worth the added complexity for this scenario. - Authorization: all 8 admin endpoints sit behind a single
router.use(requireAuth, requireAdmin, ...);requireAdminchecks Discord ID / verified admin email / linked Google admin email againstADMIN_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
writeAuditRequiredcall lives inside the same$transactionas its domain write; the rollback test (Phase 2, still passing) proves a forced failure leaves neither write behind. - Migration safety:
npx prisma migrate statusreports "Database schema is up to date!" with no drift; schema, indexes, and constraints re-verified unchanged since Phase 2. - GDPR retention: the added
gdpr.tsblock closes active memberships before nullingwebUserIdon all of a deleted user's membership rows (active-just-closed and previously-closed alike), inside the existing deletion transaction, beforewebUser.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/FIXMEand 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:handleAddcalledonAdd(candidate)without acatch, so once Phase 3 madeonAdd(handleAddMemberin 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-opcatch— 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 againstbloxclips_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
| Repository | Isolated 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.campaignIdis a requiredIntrelation toCampaign, with aCampaign.clipperGroupsback-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.
ClipperGrouppre-dates the column and there is no correct owner to backfill, so migration20260830120000_clipper_group_campaign_scopedeletes, in FK order, everyClipperGroupMembershiprow, everyClipperGrouprow, and everyCLIPPER_GROUP-categoryAuditLogrow 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_FOUND404,CAMPAIGN_DELETED409,CAMPAIGN_INACTIVE409), surfaced as distinct messages in the create modal. lockCampaignForUpdatetakesSELECT ... FOR UPDATEon the campaign row. BothcreateGroupand 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 withCAMPAIGN_INACTIVE. An active group can never be left under an inactive campaign.lockGroupForUpdatedoes the same one level down, on theClipperGrouprow. It is the second serialization point, covering mutations of an existing group rather than creation of a new one:addMember,renameGroup, and manualarchiveGroupeach take it before readingarchivedAtand hold it through their writes, and the terminal sweep takes it per group before timestamping that group. Without it,addMembercould readarchivedAt = nullfrom its snapshot, a terminal transaction could archive the group and close its memberships, andaddMembercould then insert a freshleftAt = nullmembership 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 withGROUP_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
addMemberthat just released the lock always satisfies theleftAt >= joinedAtCHECK. 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.activegoes true -> false, and nothing else.pausedandacceptingSubmissions = falseare non-terminal and touch no group. Reactivation never restores groups. - All three discovered paths that set
active = falsenow route throughupdateCampaignWithGroupLifecycle, 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.tsdeadline sweep (SYSTEMactor, triggerCAMPAIGN_DEADLINE),PUT /api/admin/campaigns/:id(ADMINactor, triggerCAMPAIGN_ADMIN_UPDATE; non-terminal updates pass straight through),DELETE /api/admin/campaigns/:idsoft delete (ADMINactor, triggerCAMPAIGN_SOFT_DELETE).
- Audit behavior is unchanged in kind: one
CLIPPER_GROUP_ARCHIVErow per archived group, written withwriteAuditRequiredinside the same transaction,ADMINfor admin transitions andSYSTEMfor the scheduler. Metadata carriestrigger(MANUALfor a hand archive) andcampaignId. src/utils/campaignBudget.tswas inspected and left alone: it only writesacceptingSubmissions/viewsFrozen, which are non-terminal.
API:
GET /accepts the existingarchivedplus optional integercampaignIdand filters in the database. An unknowncampaignIdis an empty result, not an error.GET /campaign-optionsis registered beforeGET /:groupIdand returns real ids and names,active/isDeleted, andeligibleForNewGroups. 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.eligibleForNewGroupsstays true only for active, non-deleted campaigns, so create eligibility is unchanged by this listing.POST /requires{ name, campaignId }.PATCH /:groupIdstill accepts only{ name }; acampaignIdin a PATCH body is ignored, not honoured.- Group summary and detail payloads both carry
campaignIdandcampaignName.
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>viarouter.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 UPDATEonCampaign) serializescreateGroupagainst every terminal transition.lockGroupForUpdate(SELECT ... FOR UPDATEonClipperGroup) serializesaddMember,renameGroup, and manualarchiveGroupagainst 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.
removeMemberintentionally 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") racesaddMemberagainstupdateCampaignWithGroupLifecyclewithPromise.allSettledand 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 withGROUP_ARCHIVED. Either way: campaign inactive, group archived, no membership left withleftAtnull, and the archived group rejects a subsequent rename. - A sibling test races
createGroupagainst 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.
| Check | Command | Result |
|---|---|---|
| Prisma schema | npx prisma validate | Pass — schema valid |
| Migration applies | npx prisma migrate deploy then npx prisma migrate status | Pass — 20260830120000_clipper_group_campaign_scope applied; "Database schema is up to date!" |
| Prisma client | npx prisma generate | Pass — generated cleanly under Node 22.19.0 (see Node/Prisma note below) |
| Backend typecheck | npm run build (tsc) | Pass — no errors |
| Backend domain suite | node --require ts-node/register --test src/utils/clipperGroups/domain.test.ts | Pass — 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 typecheck | npx tsc --noEmit | Pass — no errors |
| Frontend lint | npm run lint | Pass — 0 errors, 15 pre-existing warnings, none in any Clipper Groups file or in AdminCampaignDetailHeader.tsx |
| Frontend build | npm 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-through | list filter + route query, create modal states, detail read-only campaign, campaign-detail link | Not 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
leftAtis a JSnew Date()whilejoinedAtcomes from the database default, so a large app/DB clock skew could in principle trip theleftAt >= joinedAtCHECK. 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_modulesis a symlink into the main frontend worktree (a prior disk-space workaround), which breaks the Turbopack-defaultnpm run buildthere specifically; usenext build --webpackin that worktree, or run a realnpm installif 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 = falsebeing non-terminal - reactivation not restoring archived groups or memberships
- concurrent create vs. terminal transition serializing on the campaign row lock
- concurrent
addMembervs. 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 withGROUP_ARCHIVED— and asserting the invariants that hold in both: campaign inactive, group archived,currentMembersempty, no membership left withleftAtnull, 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.