From eaecc4337972d9e126ceb82a4c034ab15a92b464 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Thu, 3 Sep 2026 17:50:11 -0500 Subject: [PATCH] feat(intake): record where an upload link was sent, and how to say it (#260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...700000000_add-upload-link-contact-email.js | 17 +++++++ backend/src/db-drizzle/schema.ts | 1 + backend/src/emailTemplates.ts | 18 ++++++- backend/tests/unit/uploadLinkTemplate.test.ts | 48 +++++++++++++++++++ 4 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/migrations/1787700000000_add-upload-link-contact-email.js create mode 100644 backend/tests/unit/uploadLinkTemplate.test.ts diff --git a/backend/migrations/1787700000000_add-upload-link-contact-email.js b/backend/migrations/1787700000000_add-upload-link-contact-email.js new file mode 100644 index 0000000..a73e359 --- /dev/null +++ b/backend/migrations/1787700000000_add-upload-link-contact-email.js @@ -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;`); +}; diff --git a/backend/src/db-drizzle/schema.ts b/backend/src/db-drizzle/schema.ts index 21f5c07..a7bf63f 100644 --- a/backend/src/db-drizzle/schema.ts +++ b/backend/src/db-drizzle/schema.ts @@ -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), ]); diff --git a/backend/src/emailTemplates.ts b/backend/src/emailTemplates.ts index 9d86e4c..60c1a8c 100644 --- a/backend/src/emailTemplates.ts +++ b/backend/src/emailTemplates.ts @@ -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 = { '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.' } }; diff --git a/backend/tests/unit/uploadLinkTemplate.test.ts b/backend/tests/unit/uploadLinkTemplate.test.ts new file mode 100644 index 0000000..767f2bb --- /dev/null +++ b/backend/tests/unit/uploadLinkTemplate.test.ts @@ -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'); + }); +});