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'); + }); +});