Appearance
Sync Initiator Tracking Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Record who initiated each sync on the SyncJob record and show it in Sync History.
Architecture: A structured initiatedBy sub-object is added to SyncJob. A getInitiator(req) helper reads the authenticated identity โ full email/name for standalone JWT users, the numeric Shopify staff id for embedded users โ and is set directly on the SyncJob record at creation time (product syncs and sealed quick-adds). The client renders a best-available label.
Tech Stack: Node.js/Express (CommonJS), Mongoose, Vitest (ESM test files), React 18.
Global Constraints โ
- CommonJS in
server/(require), but test files use ESM (import). - New persisted sub-document fields MUST be declared field-by-field in the schema, or Mongoose strict mode silently drops them on write (CLAUDE.md ยง5.2). Ship a round-trip test.
- Never default
game/identity (ยง5.5).getInitiatorreturnsundefinedwhen no identity resolves โ it never invents one. - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
- End every commit message with:
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> - Tests co-located as
x.test.js; services/middleware expose_setDeps()/_resetDeps()for DI. - Design reference:
docs/superpowers/specs/2026-07-23-sync-visibility-design.md.
File Structure โ
server/models/SyncJob.jsโ addinitiatedBysub-object (Modify).server/models/SyncJob.test.jsโ round-trip schema test (Create).server/middleware/dualModeAuth.jsโ add + exportgetInitiator(req)(Modify).server/middleware/dualModeAuth.test.jsโgetInitiatorunit tests (Modify).server/routes/api.jsโ setinitiatedByon thePOST /api/syncjob and onrecordSealedAddHistory(Modify).client/src/utils/jobHelpers.jsโ addgetInitiatorLabel(job)(Modify).client/src/utils/jobHelpers.test.jsโgetInitiatorLabelunit tests (Modify).client/src/components/SyncHistory.jsxโ "Initiated by" column (Modify).client/src/components/SyncJobDetailModal.jsxโ initiator line (Modify).
Task 1: initiatedBy schema field + round-trip test โ
Files:
- Modify:
server/models/SyncJob.js(add sub-object after thefinishfield block, beforegame) - Create:
server/models/SyncJob.test.js
Interfaces:
Produces:
SyncJob.initiatedBy = { source: 'jwt'|'shopify_session', email?, name?, shopifyUserId? }โ all fields optional; absent = system-initiated.[ ] Step 1: Write the failing round-trip test
Create server/models/SyncJob.test.js (mirrors the new Model() / read-back pattern in server/models/ShopifyPokemonProductVariant.test.js โ no live Mongo needed):
js
/**
* Round-trip schema test for SyncJob.initiatedBy.
*
* Guards the ยง5.2 "silent schema drop": if any initiatedBy sub-field were
* removed from the schema, Mongoose strict mode would drop it on construction
* and these reads would come back undefined even though the value was passed in.
*/
import { describe, it, expect, beforeEach } from 'vitest';
let SyncJob;
beforeEach(async () => {
const module = await import('./SyncJob.js');
SyncJob = module.default;
});
describe('SyncJob.initiatedBy', () => {
it('round-trips a JWT initiator (email + name) through the schema', () => {
const doc = new SyncJob({
shop: 'test.myshopify.com',
setCode: 'BLB',
initiatedBy: { source: 'jwt', email: 'jane@shop.com', name: 'Jane Doe' },
});
expect(doc.initiatedBy.source).toBe('jwt');
expect(doc.initiatedBy.email).toBe('jane@shop.com');
expect(doc.initiatedBy.name).toBe('Jane Doe');
});
it('round-trips a Shopify-session initiator (numeric staff id)', () => {
const doc = new SyncJob({
shop: 'test.myshopify.com',
setCode: 'BLB',
initiatedBy: { source: 'shopify_session', shopifyUserId: '123456789' },
});
expect(doc.initiatedBy.source).toBe('shopify_session');
expect(doc.initiatedBy.shopifyUserId).toBe('123456789');
});
it('rejects an out-of-enum source', () => {
const doc = new SyncJob({
shop: 'test.myshopify.com',
setCode: 'BLB',
initiatedBy: { source: 'carrier-pigeon' },
});
const error = doc.validateSync();
expect(error.errors['initiatedBy.source']).toBeDefined();
});
it('leaves initiatedBy unset for system-initiated jobs', () => {
const doc = new SyncJob({ shop: 'test.myshopify.com', jobType: 'price_update' });
expect(doc.initiatedBy?.source).toBeUndefined();
});
});- [ ] Step 2: Run the test to verify it fails
Run: npm test -- server/models/SyncJob.test.js Expected: FAIL โ initiatedBy is dropped by strict mode, so doc.initiatedBy.source throws / is undefined, and the enum test finds no validation error.
- [ ] Step 3: Add the schema field
In server/models/SyncJob.js, insert this block immediately after the finish: String, line (the last single-card field) and before game: {:
js
// Who initiated this sync. Structured because the two auth modes carry
// different identity: standalone JWT users expose email+name; embedded
// Shopify session tokens carry only the numeric staff id (sub claim) โ
// resolving that to a name needs read_users (Plus/Advanced-gated), so it
// is intentionally out of scope. Absent = system-initiated (e.g. the
// scheduled price_update job). Declared field-by-field per ยง5.2.
initiatedBy: {
source: { type: String, enum: ['jwt', 'shopify_session'] },
email: String, // jwt mode
name: String, // jwt mode
shopifyUserId: String // embedded mode โ numeric Shopify staff id, stored as string
},- [ ] Step 4: Run the test to verify it passes
Run: npm test -- server/models/SyncJob.test.js Expected: PASS (4 tests).
- [ ] Step 5: Commit
bash
git add server/models/SyncJob.js server/models/SyncJob.test.js
git commit -m "Record who initiated a sync on the SyncJob schema"Task 2: getInitiator(req) helper โ
Files:
- Modify:
server/middleware/dualModeAuth.js(add function + export) - Modify:
server/middleware/dualModeAuth.test.js(add describe block)
Interfaces:
Consumes:
req.user(JWT mode โ Mongoose User doc with.email,.name),req.authMode('jwt'|'shopify_session_token'),req.shopifyUserId(embedded, numeric).Produces:
getInitiator(req) โ { source, email?, name?, shopifyUserId? } | undefined.[ ] Step 1: Write the failing test
Add to server/middleware/dualModeAuth.test.js (import getInitiator from the module โ it is a property on the exported function, same as _setDeps):
js
import dualModeAuth from './dualModeAuth.js';
const { getInitiator } = dualModeAuth;
describe('getInitiator', () => {
it('returns email + name for a standalone JWT user', () => {
const req = { authMode: 'jwt', user: { email: 'jane@shop.com', name: 'Jane Doe' } };
expect(getInitiator(req)).toEqual({ source: 'jwt', email: 'jane@shop.com', name: 'Jane Doe' });
});
it('returns the numeric staff id for an embedded session-token user', () => {
const req = { authMode: 'shopify_session_token', shopifyUserId: 123456789 };
expect(getInitiator(req)).toEqual({ source: 'shopify_session', shopifyUserId: '123456789' });
});
it('returns a shopify_session source with no id for the dev bypass', () => {
const req = { authMode: 'shopify_session_token' };
expect(getInitiator(req)).toEqual({ source: 'shopify_session', shopifyUserId: undefined });
});
it('returns undefined when no identity is resolvable', () => {
expect(getInitiator({})).toBeUndefined();
});
});Note: if the test file uses CommonJS require, match its existing style instead of the import above โ check the top of dualModeAuth.test.js first.
- [ ] Step 2: Run the test to verify it fails
Run: npm test -- server/middleware/dualModeAuth.test.js Expected: FAIL โ getInitiator is not a function.
- [ ] Step 3: Implement the helper
In server/middleware/dualModeAuth.js, add before the module.exports block:
js
/**
* Resolve the identity that initiated the current request, for SyncJob.initiatedBy.
* JWT/standalone users carry full email+name; embedded users carry only the
* numeric Shopify staff id (session-token `sub`) โ see getInitiator rationale
* in SyncJob.js. Returns undefined when nothing is resolvable (โ system).
*/
function getInitiator(req) {
if (req.user) {
return { source: 'jwt', email: req.user.email, name: req.user.name };
}
if (req.authMode === 'shopify_session_token') {
return {
source: 'shopify_session',
shopifyUserId: req.shopifyUserId != null ? String(req.shopifyUserId) : undefined
};
}
return undefined;
}Then add to the exports (after module.exports.optionalDualModeAuth = optionalDualModeAuth;):
js
module.exports.getInitiator = getInitiator;- [ ] Step 4: Run the test to verify it passes
Run: npm test -- server/middleware/dualModeAuth.test.js Expected: PASS (including the 4 new tests).
- [ ] Step 5: Commit
bash
git add server/middleware/dualModeAuth.js server/middleware/dualModeAuth.test.js
git commit -m "Resolve the initiating user's identity from either auth mode"Task 3: Set initiatedBy when jobs are created โ
Files:
- Modify:
server/routes/api.js(import helper;POST /api/syncjob creation ~line 2605;recordSealedAddHistory~line 4000 and its two callers ~lines 4182 and 4242)
Interfaces:
Consumes:
getInitiator(req)from Task 2;SyncJob.initiatedByfrom Task 1.[ ] Step 1: Import the helper
Near the top of server/routes/api.js, where dualModeAuth/middleware are required, add:
js
const { getInitiator } = require('../middleware/dualModeAuth');(If dualModeAuth is already required there, add this destructured require on its own line โ it reads the getInitiator property off the same module export.)
- [ ] Step 2: Set it on the product-sync job
In POST /api/sync, in the SyncJob.create({ ... }) call (the block starting shop: req.shop, setCode, game, status: 'queued', near line 2605), add as the last property:
js
finish: cardNumber ? finish : undefined,
initiatedBy: getInitiator(req)(Append initiatedBy: getInitiator(req) after the existing finish: line โ mind the trailing comma.)
- [ ] Step 3: Thread it through
recordSealedAddHistory
Change the signature (line ~4000) to accept initiatedBy:
js
function recordSealedAddHistory({ shop, game, setCode, itemName, quantityAdded, createdCount, updatedCount, failedCount = 0, totalProducts, initiatedBy }) {Add it to the SyncJob.create({ ... }) inside that function (after completedAt: now):
js
completedAt: now,
initiatedByThen pass it at both call sites. In POST /api/sealed-products/quick-add (line ~4182) add initiatedBy: getInitiator(req), to the object; in POST /api/sealed-products/quick-add-bulk (line ~4242) add initiatedBy: getInitiator(req), to the object. Both handlers have req in scope.
- [ ] Step 4: Verify nothing regressed
Run: npm test -- server/routes and npm run lint Expected: existing tests PASS; lint exits 0. (There is no Express route-test harness for api.js; the identity logic itself is covered by Task 2's unit tests. The end-to-end attribution is confirmed in Step 5.)
- [ ] Step 5: Manual end-to-end check (dev)
With the dev server running (npm run dev, DEV_BYPASS_AUTH=true), trigger a test sync:
bash
curl -s -X POST "http://localhost:3000/api/sync?shop=$DEV_BYPASS_SHOP" -H 'Content-Type: application/json' -d '{"game":"mtg","setCodes":["BLB"],"testMode":true,"testLimit":2}'Then confirm the created sync_jobs doc has an initiatedBy sub-object (query MongoDB shopify_test.sync_jobs, newest doc). Dev bypass โ { source: 'shopify_session', shopifyUserId: null }. Paste what you observe.
- [ ] Step 6: Commit
bash
git add server/routes/api.js
git commit -m "Stamp each queued sync and sealed add with who initiated it"Task 4: Display the initiator in Sync History โ
Files:
- Modify:
client/src/utils/jobHelpers.js(addgetInitiatorLabel) - Modify:
client/src/utils/jobHelpers.test.js(add tests) - Modify:
client/src/components/SyncHistory.jsx(new column, Recent Jobs table) - Modify:
client/src/components/SyncJobDetailModal.jsx(initiator line)
Interfaces:
Consumes:
job.initiatedBy(from Task 1's persisted shape).Produces:
getInitiatorLabel(job) โ string("Jane Doe" | "jane@shop.com" | "Shopify staff #123" | "System").[ ] Step 1: Write the failing test
Add to client/src/utils/jobHelpers.test.js:
js
import { getInitiatorLabel } from './jobHelpers';
describe('getInitiatorLabel', () => {
it('prefers name', () => {
expect(getInitiatorLabel({ initiatedBy: { source: 'jwt', name: 'Jane Doe', email: 'j@x.com' } })).toBe('Jane Doe');
});
it('falls back to email', () => {
expect(getInitiatorLabel({ initiatedBy: { source: 'jwt', email: 'j@x.com' } })).toBe('j@x.com');
});
it('labels an embedded staff id', () => {
expect(getInitiatorLabel({ initiatedBy: { source: 'shopify_session', shopifyUserId: '123' } })).toBe('Shopify staff #123');
});
it('returns System when there is no initiator', () => {
expect(getInitiatorLabel({})).toBe('System');
});
});(If jobHelpers.test.js uses a single top-of-file import of ./jobHelpers, add getInitiatorLabel to that existing import instead of a second import line.)
- [ ] Step 2: Run the test to verify it fails
Run: npm run test:client -- src/utils/jobHelpers.test.js Expected: FAIL โ getInitiatorLabel is not exported.
- [ ] Step 3: Implement the helper
Add to client/src/utils/jobHelpers.js:
js
/**
* Best-available label for who kicked off a job. Embedded (Shopify session)
* initiators only carry a numeric staff id โ see SyncJob.initiatedBy. Jobs
* with no initiator (scheduled price updates, pre-feature records) โ 'System'.
*/
export const getInitiatorLabel = (job) => {
const by = job?.initiatedBy;
if (!by) return 'System';
return by.name || by.email || (by.shopifyUserId ? `Shopify staff #${by.shopifyUserId}` : 'System');
};- [ ] Step 4: Run the test to verify it passes
Run: npm run test:client -- src/utils/jobHelpers.test.js Expected: PASS.
- [ ] Step 5: Add the column to the Recent Jobs table
In client/src/components/SyncHistory.jsx:
- Add to the
jobHelpersimport list (theimport { ... } from '../utils/jobHelpers';block):getInitiatorLabel,. - In the Recent Jobs
<thead>(after theStartedheader<th>), add:
jsx
<th className="px-4 py-3 text-left font-semibold border-b-2 border-black whitespace-nowrap">Initiated by</th>- In the matching
<tbody>row (after the "Started"<td>that ends near line 429), add:
jsx
<td className="px-4 py-3 whitespace-nowrap">
<Text className="text-sm">{getInitiatorLabel(job)}</Text>
</td>- [ ] Step 6: Add the initiator to the detail modal
Read client/src/components/SyncJobDetailModal.jsx to find where per-job metadata (e.g. status/timestamps) is rendered. Add getInitiatorLabel to its ../utils/jobHelpers import and render one line near that metadata:
jsx
<Text className="text-sm text-muted-foreground">Initiated by {getInitiatorLabel(job)}</Text>Match the surrounding component's existing markup/label style (use the same Text/label pattern already present in that block).
- [ ] Step 7: Verify client build + tests
Run: npm run test:client then npm run build Expected: tests PASS; build exits 0.
- [ ] Step 8: Commit
bash
git add client/src/utils/jobHelpers.js client/src/utils/jobHelpers.test.js client/src/components/SyncHistory.jsx client/src/components/SyncJobDetailModal.jsx
git commit -m "Show who initiated each sync in Sync History"Final verification (before opening the PR) โ
- [ ]
npm testexits 0. - [ ]
npm run test:clientexits 0. - [ ]
npm run test:coverageโSyncJob.js,dualModeAuth.js,jobHelpers.jseach โฅ70% on all four metrics. - [ ]
npm run lintexits 0, no new warnings. - [ ]
npm run buildexits 0. - [ ] The
sync_jobsdoc from Task 3 Step 5 shows a populatedinitiatedBy. - [ ] Parity note for the PR description (ยง5.1):
initiatedByis game-agnostic โ it lives on the sharedSyncJobmodel and the game-neutralPOST /api/sync+ sealed quick-add paths. mtg / pokemon / riftbound are all covered by the same code with no per-game branch; no per-game mirroring required.
