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)]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user