Skip to content

GDPR Deletion Hardening 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: Make GDPR deletion complete (all per-shop collections), self-healing (30-day fallback sweep for missed shop/redact webhooks), and honest (customer webhooks actually handle BuylistOrder PII).

Architecture: A new server/services/gdprService.js owns a deletion registry consumed by both the shop/redact webhook handler and a daily gdpr-sweep repeatable job that rides the existing billingQueue name-router. Customer webhooks anonymize/return BuylistOrder customer data matched by case-insensitive email. Spec: docs/superpowers/specs/2026-07-29-gdpr-deletion-design.md.

Tech Stack: Node 24 / Express / Mongoose (CommonJS), BullMQ, Vitest (test files are ESM), _setDeps()/_resetDeps() dependency-injection pattern.

Global Constraints โ€‹

  • Server code is CommonJS (require); test files are ESM (import). Tests co-located as x.test.js.
  • Services expose _setDeps()/_resetDeps() for DI โ€” prefer that over vi.mock where possible.
  • No game/shop identity fallbacks anywhere (CLAUDE.md ยง5.5).
  • Coverage โ‰ฅ70% on every touched file, all four metrics โ€” read the per-file table yourself; the threshold gate is inoperative under Vitest 4.
  • npm run lint clean; Husky pre-commit runs ESLint โ€” never --no-verify.
  • Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period.
  • Grace period is 30 days; sweep matches only { isActive: false, uninstalledAt: { $ne: null, $lte: cutoff } }.
  • customers/redact anonymizes in place (customer.email โ†’ 'redacted', unset customer.name, unset claimTokenHash); order rows/lines/payout stay.
  • Delivery is a 3-PR chain; each PR branches from the previous once merged (or stacks locally). Do not squash the PRs together.

PR 1 โ€” deletion service, complete shop/redact, drift guard โ€‹

Branch: claude/gdpr-deletion-service

Task 1: gdprService.deleteAllShopData with deletion registry โ€‹

Files:

  • Create: server/services/gdprService.js
  • Test: server/services/gdprService.test.js

Interfaces:

  • Consumes: models Store, SyncJob, BillingCycle, ProcessedOrder, BuylistOrder, VariantCreationLog, SealedProduct, StoreProduct, User (all CommonJS default exports from server/models/).

  • Produces: deleteAllShopData(shop) โ†’ Promise<{ [modelName]: number, usersUpdated: number }>; exported arrays SHOP_SCOPED_MODELS: string[], DRIFT_EXEMPT_MODELS: string[]; _setDeps(deps), _resetDeps(). Later tasks (sweep, webhook handlers) call these exact names.

  • [ ] Step 1: Write the failing tests

javascript
// server/services/gdprService.test.js
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

const gdprService = await import('./gdprService.js');
const { _setDeps, _resetDeps, SHOP_SCOPED_MODELS } = gdprService;

const SHOP = 'redact-me.myshopify.com';

function makeDeps() {
    const deps = {};
    for (const name of SHOP_SCOPED_MODELS) {
        deps[name] = { deleteMany: vi.fn().mockResolvedValue({ deletedCount: 2 }) };
    }
    deps.User = { updateMany: vi.fn().mockResolvedValue({ modifiedCount: 1 }) };
    deps.notificationService = { notifyStoreDataDeleted: vi.fn().mockResolvedValue(undefined) };
    return deps;
}

describe('deleteAllShopData', () => {
    let deps;
    beforeEach(() => { deps = makeDeps(); _setDeps(deps); });
    afterEach(() => { _resetDeps(); });

    it('deletes every registered shop-scoped collection with a { shop } filter', async () => {
        await gdprService.deleteAllShopData(SHOP);
        for (const name of SHOP_SCOPED_MODELS) {
            expect(deps[name].deleteMany).toHaveBeenCalledExactlyOnceWith({ shop: SHOP });
        }
    });

    it('pulls the shop from users.connectedStores', async () => {
        await gdprService.deleteAllShopData(SHOP);
        expect(deps.User.updateMany).toHaveBeenCalledExactlyOnceWith(
            { 'connectedStores.shop': SHOP },
            { $pull: { connectedStores: { shop: SHOP } } }
        );
    });

    it('returns per-collection counts plus usersUpdated', async () => {
        const counts = await gdprService.deleteAllShopData(SHOP);
        for (const name of SHOP_SCOPED_MODELS) expect(counts[name]).toBe(2);
        expect(counts.usersUpdated).toBe(1);
    });

    it('covers the known per-shop registry', () => {
        expect(SHOP_SCOPED_MODELS).toEqual(expect.arrayContaining([
            'Store', 'SyncJob', 'BillingCycle', 'ProcessedOrder',
            'BuylistOrder', 'VariantCreationLog', 'SealedProduct', 'StoreProduct'
        ]));
    });

    it('throws when shop is missing (no identity fallbacks)', async () => {
        await expect(gdprService.deleteAllShopData(undefined)).rejects.toThrow(/shop is required/);
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/services/gdprService.test.js Expected: FAIL โ€” cannot find module ./gdprService.js.

  • [ ] Step 3: Implement the service
javascript
// server/services/gdprService.js
/**
 * GDPR Service
 * Single owner of "delete everything we hold for a shop". Consumed by the
 * shop/redact webhook and the daily fallback sweep (missed-webhook safety net).
 */
const logger = require('../utils/logger');

let _deps = null;
function _setDeps(deps) { _deps = deps; }
function _resetDeps() { _deps = null; }
function getDeps() {
    return _deps || {
        Store: require('../models/Store'),
        SyncJob: require('../models/SyncJob'),
        BillingCycle: require('../models/BillingCycle'),
        ProcessedOrder: require('../models/ProcessedOrder'),
        BuylistOrder: require('../models/BuylistOrder'),
        VariantCreationLog: require('../models/VariantCreationLog'),
        SealedProduct: require('../models/SealedProduct'),
        StoreProduct: require('../models/StoreProduct'),
        User: require('../models/User'),
        notificationService: require('./notificationService'),
    };
}

// Deletion registry. Any Mongoose model with a top-level `shop` schema path
// MUST be listed here or in DRIFT_EXEMPT_MODELS โ€” enforced by the drift test
// in gdprService.test.js, so a new per-shop collection cannot silently
// escape GDPR deletion.
const SHOP_SCOPED_MODELS = [
    'Store',
    'SyncJob',
    'BillingCycle',
    'ProcessedOrder',
    'BuylistOrder',
    'VariantCreationLog',
    'SealedProduct',
    'StoreProduct',
];
const DRIFT_EXEMPT_MODELS = [];

async function deleteAllShopData(shop) {
    if (!shop) throw new Error('shop is required');
    const deps = getDeps();
    const counts = {};
    for (const name of SHOP_SCOPED_MODELS) {
        const result = await deps[name].deleteMany({ shop });
        counts[name] = result.deletedCount ?? 0;
    }
    const userResult = await deps.User.updateMany(
        { 'connectedStores.shop': shop },
        { $pull: { connectedStores: { shop } } }
    );
    counts.usersUpdated = userResult.modifiedCount ?? 0;
    logger.info('Deleted all shop data', { shop, counts });
    return counts;
}

module.exports = { deleteAllShopData, SHOP_SCOPED_MODELS, DRIFT_EXEMPT_MODELS, _setDeps, _resetDeps };
  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/services/gdprService.test.js Expected: PASS (5 tests).

  • [ ] Step 5: Commit
bash
git add server/services/gdprService.js server/services/gdprService.test.js
git commit -m "Add a single deletion service that removes every per-shop collection for GDPR"

Task 2: Drift guard โ€” no per-shop model escapes the registry โ€‹

Files:

  • Modify: server/services/gdprService.test.js (append a describe block)

Interfaces:

  • Consumes: SHOP_SCOPED_MODELS, DRIFT_EXEMPT_MODELS from Task 1; every file in server/models/.

  • Produces: nothing โ€” a tripwire test only.

  • [ ] Step 1: Append the drift test

javascript
// append to server/services/gdprService.test.js
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';

describe('deletion registry drift guard', () => {
    it('every model with a top-level shop path is in the registry or explicitly exempt', async () => {
        const req = createRequire(import.meta.url);
        const modelsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../models');
        for (const file of fs.readdirSync(modelsDir)) {
            if (file.endsWith('.js') && !file.endsWith('.test.js')) req(path.join(modelsDir, file));
        }
        const mongoose = req('mongoose');
        const { SHOP_SCOPED_MODELS: registry, DRIFT_EXEMPT_MODELS: exempt } = req('./gdprService.js');
        const covered = new Set([...registry, ...exempt]);
        const missing = Object.entries(mongoose.models)
            .filter(([, model]) => model.schema.path('shop'))
            .map(([name]) => name)
            .filter((name) => !covered.has(name));
        expect(missing, 'add these models to SHOP_SCOPED_MODELS in gdprService.js (or DRIFT_EXEMPT_MODELS with a comment saying why)').toEqual([]);
    });
});
  • [ ] Step 2: Run to verify it passes against today's models

Run: npx vitest run server/services/gdprService.test.js Expected: PASS. If it FAILS listing a model name, that model stores per-shop data the spec missed โ€” add it to SHOP_SCOPED_MODELS (and to makeDeps()/the registry test in Task 1), not to the exempt list, unless it is genuinely global.

  • [ ] Step 3: Prove the tripwire trips

Temporarily remove 'BuylistOrder' from SHOP_SCOPED_MODELS, run the test, confirm it FAILS naming BuylistOrder, then restore it and confirm PASS again.

  • [ ] Step 4: Commit
bash
git add server/services/gdprService.test.js
git commit -m "Fail the test suite when a per-shop model is missing from GDPR deletion"

Task 3: shop/redact delegates to the service โ€‹

Files:

  • Modify: server/routes/webhooks.js (the /shop/redact handler, currently ~lines 152โ€“202)
  • Test: server/routes/webhooks.test.js

Interfaces:

  • Consumes: deleteAllShopData(shop) from Task 1.

  • Produces: unchanged route contract โ€” POST /webhooks/shop/redact โ†’ 200 OK / 400 / 500.

  • [ ] Step 1: Replace the handler body

In server/routes/webhooks.js, replace the entire /shop/redact handler with:

javascript
router.post('/shop/redact', express.json({ verify: rawBodySaver }), verifyWebhook, async (req, res) => {
    try {
        const { shop_id, shop_domain } = req.body;
        const shop = req.webhookShop || shop_domain;

        if (!shop) {
            logger.error('Shop redaction failed: shop domain missing', { body: req.body });
            return res.status(400).send('Shop domain missing');
        }

        logger.info('Shop redaction requested', { shop, shop_id });

        const { deleteAllShopData } = require('../services/gdprService');
        const counts = await deleteAllShopData(shop);

        logger.info('Shop redaction completed', { shop, shop_id, counts });
        res.status(200).send('OK');
    } catch (error) {
        logger.error('Shop redaction webhook error', { error: error.message, stack: error.stack });
        res.status(500).send('Webhook failed');
    }
});

Remove the now-unused const SyncJob = require('../models/SyncJob'); import from the top of the file only if no other handler in the file still uses it (check with a grep before deleting; app/uninstalled still uses Store and User โ€” keep those).

  • [ ] Step 2: Update the shop/redact tests to assert full coverage

In server/routes/webhooks.test.js, the existing "shop/redact" describe block simulates deletion with Store.deleteMany/SyncJob.deleteMany directly. Replace that block with a test that exercises the service contract:

javascript
describe('shop/redact webhook logic', () => {
    it('delegates to gdprService.deleteAllShopData for complete removal', async () => {
        const { _setDeps, _resetDeps, SHOP_SCOPED_MODELS, deleteAllShopData } = await import('../services/gdprService.js');
        const deps = {};
        for (const name of SHOP_SCOPED_MODELS) {
            deps[name] = { deleteMany: vi.fn().mockResolvedValue({ deletedCount: 1 }) };
        }
        deps.User = { updateMany: vi.fn().mockResolvedValue({ modifiedCount: 0 }) };
        _setDeps(deps);
        try {
            const counts = await deleteAllShopData('closing.myshopify.com');
            expect(counts.Store).toBe(1);
            expect(counts.BillingCycle).toBe(1);
            expect(counts.BuylistOrder).toBe(1);
        } finally {
            _resetDeps();
        }
    });
});
  • [ ] Step 3: Run the touched suites

Run: npx vitest run server/routes/webhooks.test.js server/services/gdprService.test.js Expected: PASS.

  • [ ] Step 4: Full quality bar for PR 1

Run: npm test (exit 0), npm run lint (exit 0), npm run test:coverage โ€” read the per-file table: gdprService.js and webhooks.js โ‰ฅ70% on all four metrics.

  • [ ] Step 5: Commit and open PR 1
bash
git add server/routes/webhooks.js server/routes/webhooks.test.js
git commit -m "Delete billing, buylist, and log data on shop redaction instead of only store and sync records"

PR title: "Complete GDPR shop/redact deletion via a registry-backed gdprService". Body notes the drift guard and links the spec. Invoke the Reviewer agent before opening.


PR 2 โ€” fallback sweep for missed webhooks โ€‹

Branch: claude/gdpr-fallback-sweep (on top of PR 1)

Task 4: sweepExpiredUninstalledStores + Slack notification โ€‹

Files:

  • Modify: server/services/gdprService.js
  • Modify: server/services/notificationService.js (add one method after notifyNewStoreInstall)
  • Test: server/services/gdprService.test.js

Interfaces:

  • Consumes: deleteAllShopData (Task 1); notificationService.sendAdmin(message) (existing).

  • Produces: sweepExpiredUninstalledStores({ graceDays = 30 } = {}) โ†’ Promise<{ swept: number, errors: number, details: Array<{shop, counts?|error?}> }>; GDPR_SWEEP_GRACE_DAYS = 30; notificationService.notifyStoreDataDeleted(shop, counts). Task 5's processor branch calls sweepExpiredUninstalledStores() with no args.

  • [ ] Step 1: Write the failing tests

javascript
// append to server/services/gdprService.test.js
describe('sweepExpiredUninstalledStores', () => {
    const DAY = 24 * 60 * 60 * 1000;
    let deps;

    beforeEach(() => {
        vi.useFakeTimers();
        vi.setSystemTime(new Date('2026-08-30T12:00:00Z'));
        deps = makeDeps();
        deps.Store.find = vi.fn().mockReturnValue({ lean: () => Promise.resolve([]) });
        _setDeps(deps);
    });
    afterEach(() => { vi.useRealTimers(); _resetDeps(); });

    it('queries only inactive stores uninstalled at least 30 days ago', async () => {
        await gdprService.sweepExpiredUninstalledStores();
        const cutoff = new Date(Date.now() - 30 * DAY);
        expect(deps.Store.find).toHaveBeenCalledExactlyOnceWith(
            { isActive: false, uninstalledAt: { $ne: null, $lte: cutoff } },
            { shop: 1 }
        );
    });

    it('deletes data and notifies for each matched store', async () => {
        deps.Store.find = vi.fn().mockReturnValue({
            lean: () => Promise.resolve([{ shop: 'a.myshopify.com' }, { shop: 'b.myshopify.com' }])
        });
        const summary = await gdprService.sweepExpiredUninstalledStores();
        expect(summary.swept).toBe(2);
        expect(summary.errors).toBe(0);
        expect(deps.Store.deleteMany).toHaveBeenCalledWith({ shop: 'a.myshopify.com' });
        expect(deps.Store.deleteMany).toHaveBeenCalledWith({ shop: 'b.myshopify.com' });
        expect(deps.notificationService.notifyStoreDataDeleted).toHaveBeenCalledTimes(2);
    });

    it('one failing store does not stop the sweep', async () => {
        deps.Store.find = vi.fn().mockReturnValue({
            lean: () => Promise.resolve([{ shop: 'boom.myshopify.com' }, { shop: 'ok.myshopify.com' }])
        });
        deps.SyncJob.deleteMany
            .mockRejectedValueOnce(new Error('atlas hiccup'))
            .mockResolvedValue({ deletedCount: 0 });
        const summary = await gdprService.sweepExpiredUninstalledStores();
        expect(summary.swept).toBe(1);
        expect(summary.errors).toBe(1);
        expect(summary.details).toEqual(expect.arrayContaining([
            expect.objectContaining({ shop: 'boom.myshopify.com', error: 'atlas hiccup' }),
        ]));
    });

    it('honors a graceDays override', async () => {
        await gdprService.sweepExpiredUninstalledStores({ graceDays: 7 });
        const cutoff = new Date(Date.now() - 7 * DAY);
        expect(deps.Store.find).toHaveBeenCalledExactlyOnceWith(
            { isActive: false, uninstalledAt: { $ne: null, $lte: cutoff } },
            { shop: 1 }
        );
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/services/gdprService.test.js Expected: FAIL โ€” sweepExpiredUninstalledStores is not a function.

  • [ ] Step 3: Implement sweep + notification

Append to server/services/gdprService.js (before module.exports):

javascript
const GDPR_SWEEP_GRACE_DAYS = 30;

/**
 * Fallback for missed shop/redact webhooks: delete data for stores that have
 * been inactive with uninstalledAt older than the grace period. Idempotent by
 * construction โ€” deleting the Store doc removes the store from future matches.
 */
async function sweepExpiredUninstalledStores({ graceDays = GDPR_SWEEP_GRACE_DAYS } = {}) {
    const deps = getDeps();
    const cutoff = new Date(Date.now() - graceDays * 24 * 60 * 60 * 1000);
    const stores = await deps.Store.find(
        { isActive: false, uninstalledAt: { $ne: null, $lte: cutoff } },
        { shop: 1 }
    ).lean();

    const summary = { swept: 0, errors: 0, details: [] };
    for (const { shop } of stores) {
        try {
            const counts = await deleteAllShopData(shop);
            await deps.notificationService.notifyStoreDataDeleted(shop, counts);
            summary.swept++;
            summary.details.push({ shop, counts });
        } catch (error) {
            summary.errors++;
            summary.details.push({ shop, error: error.message });
            logger.error('GDPR sweep failed for store', { shop, error: error.message });
        }
    }
    logger.info('GDPR sweep completed', { swept: summary.swept, errors: summary.errors, graceDays });
    return summary;
}

Extend the exports line:

javascript
module.exports = {
    deleteAllShopData,
    sweepExpiredUninstalledStores,
    GDPR_SWEEP_GRACE_DAYS,
    SHOP_SCOPED_MODELS,
    DRIFT_EXEMPT_MODELS,
    _setDeps,
    _resetDeps,
};

In server/services/notificationService.js, add after notifyNewStoreInstall (same style):

javascript
    /**
     * Notify the operator when the GDPR sweep deletes a store's data
     * (fallback path for a missed shop/redact webhook).
     * @param {string} shop
     * @param {object} counts - per-collection deletion counts
     */
    async notifyStoreDataDeleted(shop, counts) {
        await this.sendAdmin({
            text: `GDPR sweep deleted all data for ${shop}`,
            blocks: [
                {
                    type: 'header',
                    text: { type: 'plain_text', text: 'GDPR Data Deletion', emoji: true }
                },
                {
                    type: 'section',
                    text: { type: 'mrkdwn', text: `*Shop:*\n${shop}` }
                },
                {
                    type: 'context',
                    elements: [{ type: 'mrkdwn', text: `Counts: ${JSON.stringify(counts)} โ€ข ${new Date().toISOString()}` }]
                }
            ]
        });
    }
  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/services/gdprService.test.js server/services/notificationService.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/services/gdprService.js server/services/gdprService.test.js server/services/notificationService.js
git commit -m "Sweep and delete data for stores uninstalled over 30 days when the redact webhook was missed"

Task 5: Schedule the sweep on the billing queue โ€‹

Files:

  • Modify: server/queues/billingQueue.js (add scheduleDailyGdprSweep after scheduleDailyBilling, ~line 70)
  • Modify: server/queues/processors/billingProcessor.js (name router, ~line 127)
  • Modify: server/worker.js (registration, ~line 24 destructure and ~line 270 call)
  • Test: server/queues/processors/billingProcessor.test.js (new file)

Interfaces:

  • Consumes: sweepExpiredUninstalledStores() from Task 4; existing getQueue() in billingQueue.js.

  • Produces: scheduleDailyGdprSweep() โ†’ Promise<void> exported from billingQueue.js; job name 'gdpr-sweep', jobId 'daily-gdpr-sweep'.

  • [ ] Step 1: Write the failing processor routing test

javascript
// server/queues/processors/billingProcessor.test.js
import { describe, it, expect, beforeEach, vi } from 'vitest';

vi.mock('../../services/gdprService.js', () => ({
    default: { sweepExpiredUninstalledStores: vi.fn() },
    sweepExpiredUninstalledStores: vi.fn().mockResolvedValue({ swept: 0, errors: 0, details: [] }),
}));
vi.mock('../../models/Store.js', () => ({ default: { find: vi.fn() } }));
vi.mock('../../models/BillingCycle.js', () => ({ default: { find: vi.fn() } }));
vi.mock('../../services/billingService.js', () => ({
    default: {},
    chargeUsageFee: vi.fn(),
    processOrderWebhook: vi.fn().mockResolvedValue({ processed: true }),
}));
vi.mock('../../utils/logger.js', () => ({
    default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}));

import billingProcessor from './billingProcessor.js';
import { sweepExpiredUninstalledStores } from '../../services/gdprService.js';
import { processOrderWebhook } from '../../services/billingService.js';

describe('billingProcessor name routing', () => {
    beforeEach(() => vi.clearAllMocks());

    it('routes gdpr-sweep jobs to the GDPR sweep with default grace', async () => {
        const result = await billingProcessor({ id: '1', name: 'gdpr-sweep', data: {} });
        expect(sweepExpiredUninstalledStores).toHaveBeenCalledExactlyOnceWith();
        expect(result).toEqual({ swept: 0, errors: 0, details: [] });
    });

    it('still routes process-order jobs to the order handler', async () => {
        await billingProcessor({ id: '2', name: 'process-order', data: { shop: 's.myshopify.com', orderId: 'gid://1' } });
        expect(processOrderWebhook).toHaveBeenCalledWith('s.myshopify.com', 'gid://1');
        expect(sweepExpiredUninstalledStores).not.toHaveBeenCalled();
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/queues/processors/billingProcessor.test.js Expected: the gdpr-sweep test FAILS (job falls through to chargeUsageJob).

  • [ ] Step 3: Add the route branch

In server/queues/processors/billingProcessor.js, replace the router at the bottom:

javascript
/**
 * Router: dispatches to the correct handler based on job name
 */
const billingProcessor = async (job) => {
    if (job.name === 'process-order') {
        return processOrderJob(job);
    }
    if (job.name === 'gdpr-sweep') {
        const { sweepExpiredUninstalledStores } = require('../../services/gdprService');
        return sweepExpiredUninstalledStores();
    }
    return chargeUsageJob(job);
};
  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/queues/processors/billingProcessor.test.js Expected: PASS (2 tests).

  • [ ] Step 5: Add the scheduler and worker registration

In server/queues/billingQueue.js, after scheduleDailyBilling (~line 70):

javascript
/**
 * Schedule the repeatable daily GDPR sweep (missed shop/redact fallback).
 * Safe to call multiple times (BullMQ deduplicates repeatable jobs).
 */
const scheduleDailyGdprSweep = async () => {
    const q = getQueue();

    await q.add('gdpr-sweep', {}, {
        repeat: {
            every: 24 * 60 * 60 * 1000 // 24 hours in ms
        },
        jobId: 'daily-gdpr-sweep'
    });

    logger.info('Daily GDPR sweep job scheduled (every 24h)');
};

Add scheduleDailyGdprSweep, to the module.exports block (~line 100).

In server/worker.js: extend the line-24 destructure to const { QUEUE_NAME: BILLING_QUEUE_NAME, scheduleDailyBilling, scheduleDailyGdprSweep } = require('./queues/billingQueue'); and directly after the existing await scheduleDailyBilling(); (~line 270) add await scheduleDailyGdprSweep();.

  • [ ] Step 6: Full quality bar for PR 2

Run: npm test, npm run lint, npm run test:coverage (per-file table: gdprService.js, billingProcessor.js โ‰ฅ70%). Also boot the worker locally against Docker services (docker compose up -d, npm run dev:no-worker not needed โ€” just node server/worker.js with local env) and confirm the log line Daily GDPR sweep job scheduled (every 24h) appears; Ctrl-C after.

  • [ ] Step 7: Commit and open PR 2
bash
git add server/queues/billingQueue.js server/queues/processors/billingProcessor.js server/worker.js server/queues/processors/billingProcessor.test.js
git commit -m "Run a daily sweep that deletes data for long-uninstalled stores the redact webhook missed"

Invoke the Reviewer agent before opening. PR body: note the sweep catches the four ghost stores deactivated 2026-07-29 around 2026-08-28, and that queue-triage applies if the repeatable job misbehaves after deploy (Redis double-write rule ยง5.7 is NOT triggered โ€” no env change).


PR 3 โ€” customer-data webhooks tell the truth โ€‹

Branch: claude/gdpr-customer-webhooks (on top of PR 2)

Task 6: redactCustomerData + getCustomerData in gdprService โ€‹

Files:

  • Modify: server/services/gdprService.js
  • Test: server/services/gdprService.test.js

Interfaces:

  • Consumes: BuylistOrder model (already in deps).

  • Produces: redactCustomerData(shop, email) โ†’ Promise<{ ordersRedacted: number }>; getCustomerData(shop, email) โ†’ Promise<Array<{ orderId, status, createdAt, lineCount, payout }>>. Task 7's handlers call these exact names.

  • [ ] Step 1: Write the failing tests

javascript
// append to server/services/gdprService.test.js
describe('customer data (BuylistOrder PII)', () => {
    let deps;
    beforeEach(() => {
        deps = makeDeps();
        deps.BuylistOrder.updateMany = vi.fn().mockResolvedValue({ modifiedCount: 3 });
        deps.BuylistOrder.find = vi.fn().mockReturnValue({ lean: () => Promise.resolve([]) });
        _setDeps(deps);
    });
    afterEach(() => { _resetDeps(); });

    it('redacts email, name, and claim token hash for case-insensitive email matches in one shop', async () => {
        const result = await gdprService.redactCustomerData('lgs.myshopify.com', 'Jane.Doe@Example.com');
        expect(result).toEqual({ ordersRedacted: 3 });
        const [filter, update] = deps.BuylistOrder.updateMany.mock.calls[0];
        expect(filter.shop).toBe('lgs.myshopify.com');
        expect(filter['customer.email']).toBeInstanceOf(RegExp);
        expect(filter['customer.email'].flags).toContain('i');
        expect('jane.doe@example.com').toMatch(filter['customer.email']);
        expect('jane.doe@example.com.evil.com').not.toMatch(filter['customer.email']);
        expect(update).toEqual({
            $set: { 'customer.email': 'redacted' },
            $unset: { 'customer.name': '', claimTokenHash: '' }
        });
    });

    it('escapes regex metacharacters in the email', async () => {
        await gdprService.redactCustomerData('lgs.myshopify.com', 'a+b@example.com');
        const [filter] = deps.BuylistOrder.updateMany.mock.calls[0];
        expect('a+b@example.com').toMatch(filter['customer.email']);
        expect('aab@example.com').not.toMatch(filter['customer.email']);
    });

    it('is a safe no-op without shop or email', async () => {
        expect(await gdprService.redactCustomerData('lgs.myshopify.com', undefined)).toEqual({ ordersRedacted: 0 });
        expect(await gdprService.redactCustomerData(undefined, 'x@y.com')).toEqual({ ordersRedacted: 0 });
        expect(deps.BuylistOrder.updateMany).not.toHaveBeenCalled();
    });

    it('returns the customer buylist orders for a data request', async () => {
        deps.BuylistOrder.find = vi.fn().mockReturnValue({
            lean: () => Promise.resolve([{
                _id: { toString: () => 'abc123' },
                status: 'paid',
                createdAt: new Date('2026-07-01T00:00:00Z'),
                lines: [{}, {}],
                payout: { method: 'store_credit', creditTotal: 12.5 }
            }])
        });
        const orders = await gdprService.getCustomerData('lgs.myshopify.com', 'jane@example.com');
        expect(orders).toEqual([{
            orderId: 'abc123',
            status: 'paid',
            createdAt: new Date('2026-07-01T00:00:00Z'),
            lineCount: 2,
            payout: { method: 'store_credit', creditTotal: 12.5 }
        }]);
        const [filter] = deps.BuylistOrder.find.mock.calls[0];
        expect(filter.shop).toBe('lgs.myshopify.com');
        expect('JANE@EXAMPLE.COM').toMatch(filter['customer.email']);
    });
});
  • [ ] Step 2: Run to verify failure

Run: npx vitest run server/services/gdprService.test.js Expected: FAIL โ€” redactCustomerData is not a function.

  • [ ] Step 3: Implement

Append to server/services/gdprService.js:

javascript
function escapeRegExp(str) {
    return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// BuylistOrder stores customer email raw (no lowercasing at write time), so
// GDPR matching must be a case-insensitive exact match.
function emailMatcher(email) {
    return new RegExp(`^${escapeRegExp(email)}$`, 'i');
}

/**
 * customers/redact: anonymize PII in place. Order rows, lines, and payout
 * amounts remain โ€” they are the merchant's business records; the person is gone.
 */
async function redactCustomerData(shop, email) {
    if (!shop || !email) return { ordersRedacted: 0 };
    const deps = getDeps();
    const result = await deps.BuylistOrder.updateMany(
        { shop, 'customer.email': emailMatcher(email) },
        { $set: { 'customer.email': 'redacted' }, $unset: { 'customer.name': '', claimTokenHash: '' } }
    );
    logger.info('Redacted customer buylist data', { shop, ordersRedacted: result.modifiedCount ?? 0 });
    return { ordersRedacted: result.modifiedCount ?? 0 };
}

/**
 * customers/data_request: everything we hold for this customer.
 */
async function getCustomerData(shop, email) {
    if (!shop || !email) return [];
    const deps = getDeps();
    const orders = await deps.BuylistOrder.find({ shop, 'customer.email': emailMatcher(email) }).lean();
    return orders.map((o) => ({
        orderId: o._id.toString(),
        status: o.status,
        createdAt: o.createdAt,
        lineCount: (o.lines || []).length,
        payout: o.payout,
    }));
}

Add redactCustomerData, getCustomerData, to module.exports.

  • [ ] Step 4: Run to verify pass

Run: npx vitest run server/services/gdprService.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/services/gdprService.js server/services/gdprService.test.js
git commit -m "Anonymize and export buylist customer data for GDPR customer webhooks"

Task 7: Wire the customer webhook handlers โ€‹

Files:

  • Modify: server/routes/webhooks.js (/customers/redact ~lines 117โ€“143, /customers/data_request ~lines 215โ€“255)
  • Test: server/routes/webhooks.test.js

Interfaces:

  • Consumes: redactCustomerData, getCustomerData from Task 6.

  • Produces: unchanged route contracts (200 + JSON for data_request, 200 OK for redact).

  • [ ] Step 1: Replace the /customers/redact handler body

javascript
router.post('/customers/redact', express.json({ verify: rawBodySaver }), verifyWebhook, async (req, res) => {
    try {
        const { shop_id, shop_domain, customer } = req.body;
        const shop = req.webhookShop || shop_domain;

        logger.info('Customer redaction requested', {
            shop,
            shop_id,
            customer_id: customer?.id
        });

        const { redactCustomerData } = require('../services/gdprService');
        const { ordersRedacted } = await redactCustomerData(shop, customer?.email);

        logger.info('Customer redaction completed', { shop, customer_id: customer?.id, ordersRedacted });
        res.status(200).send('OK');
    } catch (error) {
        logger.error('Customer redaction webhook error', { error: error.message });
        res.status(500).send('Webhook failed');
    }
});

Note: the old handler logged customer_email โ€” drop that; don't log the PII we're erasing.

  • [ ] Step 2: Replace the /customers/data_request handler body
javascript
router.post('/customers/data_request', express.json({ verify: rawBodySaver }), verifyWebhook, async (req, res) => {
    try {
        const { shop_id, shop_domain, customer } = req.body;
        const shop = req.webhookShop || shop_domain;

        logger.info('Customer data request received', { shop, shop_id, customer_id: customer?.id });

        const { getCustomerData } = require('../services/gdprService');
        const buylistOrders = await getCustomerData(shop, customer?.email);

        logger.info('Customer data request completed', { shop, customer_id: customer?.id, orders: buylistOrders.length });

        res.status(200).json({
            shop_domain: shop,
            customer: { id: customer?.id, email: customer?.email },
            data_stored: { buylist_orders: buylistOrders }
        });
    } catch (error) {
        logger.error('Customer data request webhook error', { error: error.message });
        res.status(500).send('Webhook failed');
    }
});
  • [ ] Step 3: Update webhook tests

In server/routes/webhooks.test.js, replace the customers/redact and customers/data_request describe blocks (which currently assert the "no customer data" no-op) with service-contract tests:

javascript
describe('customers/redact webhook logic', () => {
    it('anonymizes only the matching shop + email via gdprService', async () => {
        const { _setDeps, _resetDeps } = await import('../services/gdprService.js');
        const gdpr = await import('../services/gdprService.js');
        const BuylistOrder = { updateMany: vi.fn().mockResolvedValue({ modifiedCount: 2 }) };
        _setDeps({ BuylistOrder });
        try {
            const result = await gdpr.redactCustomerData('one-shop.myshopify.com', 'BUYER@example.com');
            expect(result.ordersRedacted).toBe(2);
            const [filter] = BuylistOrder.updateMany.mock.calls[0];
            expect(filter.shop).toBe('one-shop.myshopify.com');
            expect('buyer@example.com').toMatch(filter['customer.email']);
        } finally {
            _resetDeps();
        }
    });
});

describe('customers/data_request webhook logic', () => {
    it('returns held buylist orders instead of claiming no data is stored', async () => {
        const { _setDeps, _resetDeps } = await import('../services/gdprService.js');
        const gdpr = await import('../services/gdprService.js');
        const BuylistOrder = {
            find: vi.fn().mockReturnValue({
                lean: () => Promise.resolve([{
                    _id: { toString: () => 'o1' }, status: 'pending',
                    createdAt: new Date('2026-07-15T00:00:00Z'), lines: [{}],
                    payout: { method: 'cash', cashTotal: 5 }
                }])
            })
        };
        _setDeps({ BuylistOrder });
        try {
            const orders = await gdpr.getCustomerData('one-shop.myshopify.com', 'buyer@example.com');
            expect(orders).toHaveLength(1);
            expect(orders[0].orderId).toBe('o1');
        } finally {
            _resetDeps();
        }
    });
});
  • [ ] Step 4: Run the touched suites

Run: npx vitest run server/routes/webhooks.test.js server/services/gdprService.test.js Expected: PASS.

  • [ ] Step 5: Commit
bash
git add server/routes/webhooks.js server/routes/webhooks.test.js
git commit -m "Make customer GDPR webhooks anonymize and export buylist data instead of claiming none exists"

Task 8: Documentation + PR 3 โ€‹

Files:

  • Modify: GDPR_COMPLIANCE.md

  • [ ] Step 1: Update GDPR_COMPLIANCE.md

Rewrite the three webhook sections to match reality:

  • customers/data_request: returns data_stored.buylist_orders (order id, status, createdAt, line count, payout) matched by shop + case-insensitive email from buylist_orders. Remove the "does not store customer-specific data" claims.

  • customers/redact: anonymizes BuylistOrder.customer (email โ†’ redacted, name removed, claimTokenHash removed); business records retained.

  • shop/redact: deletes via the gdprService registry โ€” list all collections (stores, sync_jobs, billingcycles, processedorders, buylist_orders, plus VariantCreationLog, per-shop SealedProduct, StoreProduct docs) and the users.connectedStores pull.

  • Add a "Fallback sweep" section: daily gdpr-sweep job on the billing queue deletes data for stores isActive: false with uninstalledAt โ‰ฅ30 days old, covering missed webhooks; Slack admin notification per deletion.

  • [ ] Step 2: Full quality bar for PR 3

Run: npm test, npm run lint, npm run test:coverage (per-file table for gdprService.js, webhooks.js).

  • [ ] Step 3: Commit and open PR 3
bash
git add GDPR_COMPLIANCE.md
git commit -m "docs: describe the real GDPR webhook behavior and the fallback sweep"

Invoke the Reviewer agent, then the /cso suggestion applies (webhooks touched) โ€” surface it to Brent rather than auto-running.


Verification (whole feature) โ€‹

  • npm test exits 0; new suites: gdprService.test.js, billingProcessor.test.js.
  • Drift guard demonstrably trips when a registry entry is removed (Task 2 Step 3).
  • Worker boot logs Daily GDPR sweep job scheduled (every 24h).
  • Grep bars: no myshopify.com literals added outside tests; no || 'mtg'-style identity defaults; no new aggregations (nothing to check against ยง5.6).
  • The four ghost stores deactivated 2026-07-29 (uninstalledAt 2026-07-29) get swept automatically on the first run after 2026-08-28.