feat(intake): add the submission notification template (#224)
Editable from the settings screen like every other template. reviewUrl is the only required placeholder: a notification with no link in it still sends, still looks fine in the log, and is useless to whoever receives it, which is what the required-placeholder validation exists to catch. The two 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. A test asserts the template offers no way to publish. That the email cannot publish 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 in the body would be one nobody reviewed. Where the notification goes is an admin 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. Empty is the default and means do not notify, which is a working configuration. INTAKE_ACTION_SECRET warns rather than fails at boot, like the drafting key. Being told an item arrived matters far more than being able to discard it in one click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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<TemplateKey, TemplateDefinition> = {
|
||||
'{{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<string, string> = {
|
||||
// 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'
|
||||
};
|
||||
|
||||
@@ -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)]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user