Feature/224 intake notification #263
@@ -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);
|
||||||
|
|||||||
@@ -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