Feature/224 intake notification #263

Merged
bermudalamb merged 8 commits from feature/224-intake-notification into main 2026-09-01 15:08:49 -05:00
3 changed files with 191 additions and 0 deletions
Showing only changes of commit 81886f84c2 - Show all commits
+2
View File
@@ -10,6 +10,7 @@ import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
import adminCategoriesRouter from './routes/adminCategories'; import adminCategoriesRouter from './routes/adminCategories';
import adminTagsRouter from './routes/adminTags'; import adminTagsRouter from './routes/adminTags';
import adminUploadLinksRouter from './routes/adminUploadLinks'; import adminUploadLinksRouter from './routes/adminUploadLinks';
import adminItemDraftsRouter from './routes/adminItemDrafts';
import intakeRouter from './routes/intake'; import intakeRouter from './routes/intake';
import adminVersionRouter from './routes/adminVersion'; import adminVersionRouter from './routes/adminVersion';
import filtersRouter from './routes/filters'; import filtersRouter from './routes/filters';
@@ -79,6 +80,7 @@ app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRoute
app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter); app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter);
app.use('/api/admin/tags', requireAdminGate, adminTagsRouter); app.use('/api/admin/tags', requireAdminGate, adminTagsRouter);
app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter); app.use('/api/admin/upload-links', requireAdminGate, adminUploadLinksRouter);
app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter);
app.use('/api/admin/version', requireAdminGate, adminVersionRouter); app.use('/api/admin/version', requireAdminGate, adminVersionRouter);
app.use('/api/admin', requireAdminGate, adminRouter); app.use('/api/admin', requireAdminGate, adminRouter);
app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter);
+57
View File
@@ -0,0 +1,57 @@
import { Router, Request, Response } from 'express';
import { pool } from '../db';
import { asyncRoute } from '../asyncRoute';
const router = Router();
/**
* The review queue: everything waiting for a person, with what a person needs
* in order to decide.
*
* Columns are spelled out rather than `d.*, i.*` so that a column added later —
* a cost, a token count, an internal error — does not silently start being sent
* to the browser. That matters most for the join to upload_links, which carries
* the token digest: only the label is taken.
*
* Images come back as an aggregate rather than a second round trip, matching
* how itemSelect.ts builds them.
*/
const DRAFT_SELECT = `
SELECT d.item_id, d.state, d.attempts, d.submitter_note, d.ai_error,
d.ai_name, d.ai_description, d.ai_category_id, d.ai_tag_names,
d.ai_suggested_price_cents, d.price_source, d.model, d.drafted_at,
d.created_at,
i.name AS item_name, i.description AS item_description,
i.price_cents, i.status,
l.label AS upload_link_label,
COALESCE((
SELECT json_agg(json_build_object('id', img.id, 'image_path', img.image_path)
ORDER BY img.sort_order)
FROM item_images img WHERE img.item_id = d.item_id
), '[]'::json) AS images
FROM item_drafts d
JOIN items i ON i.id = d.item_id
LEFT JOIN upload_links l ON l.id = d.upload_link_id
`;
/**
* Discarded rows are excluded by default rather than deleted.
*
* Discard has to be recoverable, because it is one click away in what amounts
* to an inbox — but a discarded row left in the default view would compete for
* attention with work that still needs doing.
*/
router.get(
'/',
asyncRoute(async (req: Request, res: Response) => {
const state = typeof req.query.state === 'string' ? req.query.state : null;
const { rows } = state
? await pool.query(`${DRAFT_SELECT} WHERE d.state = $1 ORDER BY d.created_at DESC`, [state])
: await pool.query(`${DRAFT_SELECT} WHERE d.state <> 'discarded' ORDER BY d.created_at DESC`);
res.json({ drafts: rows });
})
);
export default router;
@@ -0,0 +1,132 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { resetDb, closeDb } from './setup/testDb';
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await pool.end();
await closeDb();
});
interface SeedOptions {
state?: string;
aiName?: string | null;
priceSource?: string;
}
/**
* A submission as the intake route leaves it: a pending item priced at the
* migration's 8000 default, a draft beside it, and one photo.
*/
async function seedDraft(overrides: SeedOptions = {}): Promise<number> {
const { rows } = await pool.query<{ id: number }>(
`INSERT INTO items (name, status) VALUES ('Submission 2026-09-01', 'pending') RETURNING id`
);
const itemId = rows[0]!.id;
await pool.query(
`INSERT INTO item_drafts (item_id, submitter_note, state, ai_name, ai_description, price_source)
VALUES ($1, 'found in a loft', $2, $3, 'A blue vase.', $4)`,
[
itemId,
overrides.state ?? 'ready',
overrides.aiName === undefined ? 'Blue vase' : overrides.aiName,
overrides.priceSource ?? 'ai'
]
);
await pool.query(
`INSERT INTO item_images (item_id, image_path, sort_order) VALUES ($1, '/uploads/a.jpg', 0)`,
[itemId]
);
return itemId;
}
const itemIds = (body: { drafts: { item_id: number }[] }): number[] =>
body.drafts.map((draft) => draft.item_id);
describe('GET /api/admin/item-drafts', () => {
it('returns the draft with its item, photos and note', async () => {
const itemId = await seedDraft();
const res = await request(app).get('/api/admin/item-drafts');
expect(res.status).toBe(200);
const draft = res.body.drafts.find((d: { item_id: number }) => d.item_id === itemId);
expect(draft).toBeDefined();
expect(draft.submitter_note).toBe('found in a loft');
expect(draft.ai_name).toBe('Blue vase');
expect(draft.price_cents).toBe(8000);
expect(draft.price_source).toBe('ai');
expect(draft.images).toHaveLength(1);
});
// The token digest lives on upload_links and must never be selected into a
// response. Spelling the columns is what prevents that; this is the assertion
// that keeps it spelled.
it('never sends the upload link token digest', async () => {
await seedDraft();
const res = await request(app).get('/api/admin/item-drafts');
expect(JSON.stringify(res.body)).not.toContain('token_hash');
});
it('filters by state', async () => {
const ready = await seedDraft({ state: 'ready' });
const failed = await seedDraft({ state: 'failed' });
const res = await request(app).get('/api/admin/item-drafts?state=failed');
expect(itemIds(res.body)).toContain(failed);
expect(itemIds(res.body)).not.toContain(ready);
});
// Discarded is recoverable, so it has to be reachable — but it must not sit
// in the default view competing with work that still needs doing.
it('hides discarded drafts unless they are asked for', async () => {
const discarded = await seedDraft({ state: 'discarded' });
const def = await request(app).get('/api/admin/item-drafts');
expect(itemIds(def.body)).not.toContain(discarded);
const asked = await request(app).get('/api/admin/item-drafts?state=discarded');
expect(itemIds(asked.body)).toContain(discarded);
});
/**
* The gate is disabled when ADMIN_GATE_SECRET is unset, which is how the rest
* of this suite runs, so it is set here for the length of this test alone.
* Worth asserting: the gate goes on the mount in app.ts rather than inside the
* router, and leaving it off a new mount is a silent hole.
*/
describe('with the admin gate configured', () => {
const original = process.env.ADMIN_GATE_SECRET;
beforeAll(() => {
process.env.ADMIN_GATE_SECRET = 'integration-secret';
});
afterAll(() => {
if (original === undefined) delete process.env.ADMIN_GATE_SECRET;
else process.env.ADMIN_GATE_SECRET = original;
});
it('refuses a request with no gate header', async () => {
await seedDraft();
const res = await request(app).get('/api/admin/item-drafts');
expect(res.status).toBe(403);
});
it('allows a request carrying the secret', async () => {
await seedDraft();
const res = await request(app)
.get('/api/admin/item-drafts')
.set('X-Admin-Gate', 'integration-secret');
expect(res.status).toBe(200);
});
});
});