diff --git a/backend/src/adminSettings.ts b/backend/src/adminSettings.ts index 4dc1879..660d406 100644 --- a/backend/src/adminSettings.ts +++ b/backend/src/adminSettings.ts @@ -34,7 +34,13 @@ const DEFINITIONS = [ name: 'draftingModel', type: 'choice', fallback: DEFAULT_DRAFTING_MODEL - } + }, + // Where the intake notification goes (#224). A setting rather than an + // environment variable, for the same reason drafting_model is one: it is + // changed by whoever runs the shop, not by whoever deploys it, and a redeploy + // to change an address would be absurd. Empty means do not notify, which is + // the default and a working configuration. + { key: 'intake_notify_email', name: 'intakeNotifyEmail', type: 'text', fallback: '' } ] as const; type Definition = (typeof DEFINITIONS)[number]; diff --git a/backend/src/emailTemplates.ts b/backend/src/emailTemplates.ts index 010c674..9d86e4c 100644 --- a/backend/src/emailTemplates.ts +++ b/backend/src/emailTemplates.ts @@ -17,7 +17,8 @@ export type TemplateKey = | 'favoriteSold' | 'favoriteWithdrawn' | 'cartReminder' - | 'emailChanged'; + | 'emailChanged' + | 'intakeDraft'; export interface TemplateDefinition { /** Shown in the admin so a card is identifiable without reading its body. */ @@ -122,6 +123,36 @@ export const TEMPLATES: Record = { '{{itemList}}\n\n' + 'Items are held for {{holdDuration}} from when they were added.\n\n' + '[View your cart]({{cartUrl}}) before your reservation expires.' + }, + + intakeDraft: { + label: 'Item submitted for review', + // Only the review link. The signed shortcuts are absent whenever + // INTAKE_ACTION_SECRET is unset, and requiring them would make an + // unconfigured environment unable to send this at all. + required: ['reviewUrl'], + available: [ + 'itemName', + 'draftName', + 'draftDescription', + 'price', + 'submitterNote', + 'linkLabel', + 'reviewUrl', + 'regenerateUrl', + 'discardUrl' + ], + defaultSubject: 'An item was submitted: {{draftName}}', + defaultBody: + 'Someone sent in an item through {{linkLabel}}.\n\n' + + '**{{draftName}}**\n\n' + + '{{draftDescription}}\n\n' + + 'Suggested price: {{price}}\n\n' + + "The sender's note: {{submitterNote}}\n\n" + + '[Review and publish it]({{reviewUrl}})\n\n' + + 'Nothing is listed until you publish it from that screen, and the price ' + + 'above is a suggestion rather than a decision.\n\n' + + '[Ask for another draft]({{regenerateUrl}}) - [Discard it]({{discardUrl}})' } }; @@ -225,6 +256,14 @@ export const SAMPLE_VALUES: Record = { // Fallbacks only. The admin preview overrides both from the live settings, // so the pane shows the duration that would actually be sent rather than a // plausible-looking number that disagrees with it. + draftName: 'Blue stoneware vase', + draftDescription: 'A hand-thrown vase with a chipped base.', + price: '$80.00', + submitterNote: 'Found in a loft clearance.', + linkLabel: 'Autumn drop-off', + reviewUrl: 'https://example.com/admin', + regenerateUrl: 'https://example.com/api/intake-actions/1/regenerate?expires=0&sig=sample', + discardUrl: 'https://example.com/api/intake-actions/1/discard?expires=0&sig=sample', expiresIn: 'one hour', holdDuration: '24 hours' }; diff --git a/backend/src/envValidation.ts b/backend/src/envValidation.ts index 4ba3d98..345321e 100644 --- a/backend/src/envValidation.ts +++ b/backend/src/envValidation.ts @@ -170,6 +170,19 @@ function checkAdminGate(env: NodeJS.ProcessEnv): string[] { // without its description written. Silence would be the wrong answer too: an // operator who believes drafting is on and finds every item undrafted has // nothing to tell them why. +// Optional, like the drafting key below. Absent, the notification still sends +// with its review link and simply carries no shortcuts — being told an item +// arrived matters far more than being able to discard it in one click. +function checkIntakeActionSecret(env: NodeJS.ProcessEnv): string[] { + if (isPresent(env, 'INTAKE_ACTION_SECRET')) { + return []; + } + return [ + 'INTAKE_ACTION_SECRET is not set — intake notifications will link to the review queue ' + + 'but carry no regenerate or discard shortcuts.' + ]; +} + function checkDraftingKey(env: NodeJS.ProcessEnv): string[] { if (isPresent(env, 'ANTHROPIC_API_KEY')) { return []; @@ -223,6 +236,6 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation { ...mail.errors, ...uploads.errors ], - warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env)] + warnings: [...mail.warnings, ...checkAdminGate(env), ...uploads.warnings, ...checkDraftingKey(env), ...checkIntakeActionSecret(env)] }; } diff --git a/backend/tests/unit/emailTemplates.test.ts b/backend/tests/unit/emailTemplates.test.ts index 8311525..302c533 100644 --- a/backend/tests/unit/emailTemplates.test.ts +++ b/backend/tests/unit/emailTemplates.test.ts @@ -274,3 +274,37 @@ describe('every template can address the customer', () => { ); }); }); + +describe('the intake notification template', () => { + // Without the review link the email is a notification you cannot act on. + it('requires the review url', () => { + expect(missingPlaceholders('intakeDraft', 'An item arrived.')).toContain('reviewUrl'); + }); + + it('accepts a body carrying the review url', () => { + expect(missingPlaceholders('intakeDraft', 'Review it: {{reviewUrl}}')).toEqual([]); + }); + + // The signed links are deliberately optional. They are absent whenever + // INTAKE_ACTION_SECRET is unset, and a template demanding them would leave an + // unconfigured environment unable to send this at all. + it('does not require the signed action links', () => { + const missing = missingPlaceholders('intakeDraft', '{{reviewUrl}}'); + expect(missing).not.toContain('discardUrl'); + expect(missing).not.toContain('regenerateUrl'); + }); + + it('offers the drafted copy to the template author', () => { + for (const name of ['itemName', 'draftName', 'draftDescription', 'price', 'submitterNote']) { + expect(TEMPLATES.intakeDraft.available).toContain(name); + } + }); + + // The email must never be able to publish. That is what bounds the risk taken + // by pricing items on arrival, and it is a property of the copy as much as of + // the routes — a publish link here would be one nobody reviewed. + it('offers no way to publish', () => { + expect(TEMPLATES.intakeDraft.available).not.toContain('publishUrl'); + expect(TEMPLATES.intakeDraft.defaultBody).not.toMatch(/publishUrl/); + }); +}); diff --git a/backend/tests/unit/envValidation.test.ts b/backend/tests/unit/envValidation.test.ts index 154c13d..b87df83 100644 --- a/backend/tests/unit/envValidation.test.ts +++ b/backend/tests/unit/envValidation.test.ts @@ -236,4 +236,19 @@ describe('UPLOADS_BASE_URL', () => { expect(warnings.join(' ')).not.toMatch(/ANTHROPIC_API_KEY/); }); }); + // #224. MINIMAL has no INTAKE_ACTION_SECRET, so it is already the absent case. + describe('the intake action secret', () => { + it('is not required', () => { + expect(validateEnv(MINIMAL).errors).toEqual([]); + }); + + it('warns when it is absent', () => { + expect(validateEnv(MINIMAL).warnings.join(' ')).toMatch(/INTAKE_ACTION_SECRET/); + }); + + it('says nothing when it is set', () => { + const { warnings } = validateEnv(withEnv({ INTAKE_ACTION_SECRET: 'a-secret' })); + expect(warnings.join(' ')).not.toMatch(/INTAKE_ACTION_SECRET/); + }); + }); });