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',
|
name: 'draftingModel',
|
||||||
type: 'choice',
|
type: 'choice',
|
||||||
fallback: DEFAULT_DRAFTING_MODEL
|
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;
|
] as const;
|
||||||
|
|
||||||
type Definition = (typeof DEFINITIONS)[number];
|
type Definition = (typeof DEFINITIONS)[number];
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export type TemplateKey =
|
|||||||
| 'favoriteSold'
|
| 'favoriteSold'
|
||||||
| 'favoriteWithdrawn'
|
| 'favoriteWithdrawn'
|
||||||
| 'cartReminder'
|
| 'cartReminder'
|
||||||
| 'emailChanged';
|
| 'emailChanged'
|
||||||
|
| 'intakeDraft';
|
||||||
|
|
||||||
export interface TemplateDefinition {
|
export interface TemplateDefinition {
|
||||||
/** Shown in the admin so a card is identifiable without reading its body. */
|
/** 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' +
|
'{{itemList}}\n\n' +
|
||||||
'Items are held for {{holdDuration}} from when they were added.\n\n' +
|
'Items are held for {{holdDuration}} from when they were added.\n\n' +
|
||||||
'[View your cart]({{cartUrl}}) before your reservation expires.'
|
'[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,
|
// 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
|
// so the pane shows the duration that would actually be sent rather than a
|
||||||
// plausible-looking number that disagrees with it.
|
// 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',
|
expiresIn: 'one hour',
|
||||||
holdDuration: '24 hours'
|
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
|
// without its description written. Silence would be the wrong answer too: an
|
||||||
// operator who believes drafting is on and finds every item undrafted has
|
// operator who believes drafting is on and finds every item undrafted has
|
||||||
// nothing to tell them why.
|
// 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[] {
|
function checkDraftingKey(env: NodeJS.ProcessEnv): string[] {
|
||||||
if (isPresent(env, 'ANTHROPIC_API_KEY')) {
|
if (isPresent(env, 'ANTHROPIC_API_KEY')) {
|
||||||
return [];
|
return [];
|
||||||
@@ -223,6 +236,6 @@ export function validateEnv(env: NodeJS.ProcessEnv): EnvValidation {
|
|||||||
...mail.errors,
|
...mail.errors,
|
||||||
...uploads.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/);
|
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