Feature/224 intake notification #263
@@ -10,6 +10,7 @@ import adminEmailTemplatesRouter from './routes/adminEmailTemplates';
|
||||
import adminCategoriesRouter from './routes/adminCategories';
|
||||
import adminTagsRouter from './routes/adminTags';
|
||||
import adminUploadLinksRouter from './routes/adminUploadLinks';
|
||||
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
||||
import intakeRouter from './routes/intake';
|
||||
import adminVersionRouter from './routes/adminVersion';
|
||||
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/tags', requireAdminGate, adminTagsRouter);
|
||||
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', requireAdminGate, adminRouter);
|
||||
app.use('/api/customers/me/addresses', shippingAddressesRouter);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user