feat(intake): record where an upload link was sent, and how to say it (#260)

Adds upload_links.contact_email and the uploadLink mail template.

The column is nullable on purpose. Links already exist in QA and a migration cannot invent addresses for them, so they are grandfathered rather than backfilled with something untrue; the requirement belongs in the create route, which is where new links are actually made.

The template requires submitUrl, the same guard verification has on verifyUrl. An email inviting somebody to send in photos, with no way for them to do it, sends perfectly happily and looks fine in the log — it is the one failure here worth making impossible, and a test asserts the default body satisfies the guard it declares.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:50:11 -05:00
co-authored by Claude Opus 5
parent 823796b92a
commit eaecc43379
4 changed files with 83 additions and 1 deletions
@@ -0,0 +1,17 @@
exports.up = (pgm) => {
pgm.sql(`
-- Where the link was sent (#260). Nullable, and deliberately so: links
-- already exist in QA and a migration cannot invent an address for them, so
-- they are grandfathered rather than backfilled with something untrue.
--
-- The requirement lives in the create route instead, which is where new
-- links are actually made. A NOT NULL column would have forced a choice
-- between inventing data and refusing to migrate.
ALTER TABLE upload_links
ADD COLUMN IF NOT EXISTS contact_email TEXT;
`);
};
exports.down = (pgm) => {
pgm.sql(`ALTER TABLE upload_links DROP COLUMN IF EXISTS contact_email;`);
};
+1
View File
@@ -280,6 +280,7 @@ export const uploadLinks = pgTable("upload_links", {
maxSubmissions: integer("max_submissions"),
lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: 'string' }),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
contactEmail: text("contact_email"),
}, (table) => [
unique("upload_links_token_hash_key").on(table.tokenHash),
]);
+17 -1
View File
@@ -18,7 +18,8 @@ export type TemplateKey =
| 'favoriteWithdrawn'
| 'cartReminder'
| 'emailChanged'
| 'intakeDraft';
| 'intakeDraft'
| 'uploadLink';
export interface TemplateDefinition {
/** Shown in the admin so a card is identifiable without reading its body. */
@@ -153,6 +154,21 @@ export const TEMPLATES: Record<TemplateKey, TemplateDefinition> = {
'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}})'
},
uploadLink: {
label: 'Upload link for a contributor',
// The link itself, for the same reason verification requires verifyUrl: an
// email inviting somebody to send in photos, with no way to do it, sends
// perfectly happily and wastes everyone's time.
required: ['submitUrl'],
available: ['submitUrl', 'label', 'submissionsAllowed'],
defaultSubject: 'Send us your items',
defaultBody:
'You can send us photos of items you would like us to sell.\n\n' +
'[Send in an item]({{submitUrl}})\n\n' +
'You can send {{submissionsAllowed}}. Photograph one item at a time, and tell us anything you know about it — where it came from, what it is made of, any damage. A photo cannot show any of that.\n\n' +
'Keep this link to yourself: anyone who has it can send us items in your name.'
}
};
@@ -0,0 +1,48 @@
import { TEMPLATES, missingPlaceholders, renderTemplate } from '../../src/emailTemplates';
/**
* The template that carries an upload link (#260).
*
* The one failure worth making impossible is a link email with no link in it:
* it sends, it looks fine in the log, and it is useless to the person who gets
* it. That is the same guard `verification` has on `verifyUrl`.
*/
describe('the upload link template', () => {
it('is offered in the admin like the others', () => {
expect(TEMPLATES.uploadLink.label).toBeTruthy();
});
it('requires the submit link', () => {
expect(TEMPLATES.uploadLink.required).toContain('submitUrl');
});
it('refuses a body that has no link in it', () => {
expect(missingPlaceholders('uploadLink', 'Hello, a link is on its way.')).toEqual(['submitUrl']);
});
it('accepts a body that has one', () => {
expect(missingPlaceholders('uploadLink', 'Send your items: {{submitUrl}}')).toEqual([]);
});
it('offers the label and the allowance as placeholders', () => {
expect(TEMPLATES.uploadLink.available).toEqual(
expect.arrayContaining(['submitUrl', 'label', 'submissionsAllowed'])
);
});
// The default body has to satisfy the guard it declares, or the feature ships
// unable to send its own default.
it('has a default body that satisfies its own requirement', () => {
expect(missingPlaceholders('uploadLink', TEMPLATES.uploadLink.defaultBody)).toEqual([]);
});
it('renders the link into the body', () => {
const rendered = renderTemplate(
'uploadLink',
{ subject: 'Send us your items', body: 'Here: {{submitUrl}}' },
{ submitUrl: 'https://example.com/submit/abc', label: 'Sarah', submissionsAllowed: '25 items' }
);
expect(rendered.html).toContain('https://example.com/submit/abc');
});
});