Skip to content

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). getInitiator returns undefined when 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 โ€” add initiatedBy sub-object (Modify).
  • server/models/SyncJob.test.js โ€” round-trip schema test (Create).
  • server/middleware/dualModeAuth.js โ€” add + export getInitiator(req) (Modify).
  • server/middleware/dualModeAuth.test.js โ€” getInitiator unit tests (Modify).
  • server/routes/api.js โ€” set initiatedBy on the POST /api/sync job and on recordSealedAddHistory (Modify).
  • client/src/utils/jobHelpers.js โ€” add getInitiatorLabel(job) (Modify).
  • client/src/utils/jobHelpers.test.js โ€” getInitiatorLabel unit 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 the finish field block, before game)
  • 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/sync job creation ~line 2605; recordSealedAddHistory ~line 4000 and its two callers ~lines 4182 and 4242)

Interfaces:

  • Consumes: getInitiator(req) from Task 2; SyncJob.initiatedBy from 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,
        initiatedBy

Then 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 (add getInitiatorLabel)
  • 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:

  1. Add to the jobHelpers import list (the import { ... } from '../utils/jobHelpers'; block): getInitiatorLabel,.
  2. In the Recent Jobs <thead> (after the Started header <th>), add:
jsx
                          <th className="px-4 py-3 text-left font-semibold border-b-2 border-black whitespace-nowrap">Initiated by</th>
  1. 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 test exits 0.
  • [ ] npm run test:client exits 0.
  • [ ] npm run test:coverage โ€” SyncJob.js, dualModeAuth.js, jobHelpers.js each โ‰ฅ70% on all four metrics.
  • [ ] npm run lint exits 0, no new warnings.
  • [ ] npm run build exits 0.
  • [ ] The sync_jobs doc from Task 3 Step 5 shows a populated initiatedBy.
  • [ ] Parity note for the PR description (ยง5.1): initiatedBy is game-agnostic โ€” it lives on the shared SyncJob model and the game-neutral POST /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.