Appearance
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 asx.test.js. - Services expose
_setDeps()/_resetDeps()for DI โ prefer that overvi.mockwhere possible. - No
game/shopidentity 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 lintclean; 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/redactanonymizes in place (customer.emailโ'redacted', unsetcustomer.name, unsetclaimTokenHash); 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 fromserver/models/).Produces:
deleteAllShopData(shop) โ Promise<{ [modelName]: number, usersUpdated: number }>; exported arraysSHOP_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_MODELSfrom Task 1; every file inserver/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/redacthandler, 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โ 200OK/ 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 afternotifyNewStoreInstall) - 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 callssweepExpiredUninstalledStores()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(addscheduleDailyGdprSweepafterscheduleDailyBilling, ~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; existinggetQueue()inbillingQueue.js.Produces:
scheduleDailyGdprSweep() โ Promise<void>exported frombillingQueue.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:
BuylistOrdermodel (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,getCustomerDatafrom Task 6.Produces: unchanged route contracts (200 + JSON for data_request, 200
OKfor redact).[ ] Step 1: Replace the
/customers/redacthandler 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_requesthandler 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: returnsdata_stored.buylist_orders(order id, status, createdAt, line count, payout) matched by shop + case-insensitive email frombuylist_orders. Remove the "does not store customer-specific data" claims.customers/redact: anonymizesBuylistOrder.customer(email โredacted, name removed, claimTokenHash removed); business records retained.shop/redact: deletes via thegdprServiceregistry โ list all collections (stores,sync_jobs,billingcycles,processedorders,buylist_orders, plusVariantCreationLog, per-shopSealedProduct,StoreProductdocs) and theusers.connectedStorespull.Add a "Fallback sweep" section: daily
gdpr-sweepjob on the billing queue deletes data for storesisActive: falsewithuninstalledAtโฅ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 testexits 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.comliterals 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.
