contact.ts143 lines · main
| 1 | import { newId, ValidationError } from '@briven/shared'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { contactMessages, contactTopics, type ContactTopic } from '../db/schema.js'; |
| 5 | import { generateTicketNumber, primaryTopicCode } from './support-tickets.js'; |
| 6 | |
| 7 | const NAME_CAP = 200; |
| 8 | const EMAIL_CAP = 320; |
| 9 | const SUBJECT_CAP = 200; |
| 10 | const COUNTRY_CAP = 100; |
| 11 | const MESSAGE_CAP = 8_000; |
| 12 | |
| 13 | export interface CreateContactMessageInput { |
| 14 | name: string; |
| 15 | email: string; |
| 16 | topic: string; |
| 17 | /** Free-text "what's this about" line. Optional. */ |
| 18 | subject?: string | null; |
| 19 | message: string; |
| 20 | /** Visitor country auto-detected on /contact (locked field). Optional. */ |
| 21 | country?: string | null; |
| 22 | ipHash?: string | null; |
| 23 | userAgent?: string | null; |
| 24 | } |
| 25 | |
| 26 | function assertTopic(t: string): asserts t is ContactTopic { |
| 27 | if (!(contactTopics as readonly string[]).includes(t)) { |
| 28 | throw new ValidationError(`topic must be one of: ${contactTopics.join(', ')}`); |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | function trimWithCap(s: string | undefined | null, cap: number, field: string): string { |
| 33 | const trimmed = (s ?? '').trim(); |
| 34 | if (trimmed.length > cap) { |
| 35 | throw new ValidationError(`${field} exceeds ${cap}-character cap`); |
| 36 | } |
| 37 | return trimmed; |
| 38 | } |
| 39 | |
| 40 | function assertEmail(email: string): void { |
| 41 | if (email.length > EMAIL_CAP) { |
| 42 | throw new ValidationError(`email exceeds ${EMAIL_CAP}-character cap`); |
| 43 | } |
| 44 | if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { |
| 45 | throw new ValidationError('email is not a valid address'); |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | export interface CreateContactMessageResult { |
| 50 | /** The contact message id (ctc_<ULID>) — the public reference id. */ |
| 51 | id: string; |
| 52 | /** |
| 53 | * The human ticket number WITHOUT the leading '#' (e.g. SUP260629-000001), |
| 54 | * or null when the submission carried no routing tag (stays a plain |
| 55 | * contact message). The route renders it with the '#'. |
| 56 | */ |
| 57 | ticketNumber: string | null; |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Insert a public contact-form submission. Mirrors createMigrationRequest: |
| 62 | * validates + caps each field, generates a prefixed ULID, and returns the |
| 63 | * new id (which the caller surfaces to the visitor as a reference). The |
| 64 | * stored email is for the operator to reply privately — never echoed back |
| 65 | * to the website. |
| 66 | * |
| 67 | * Ticketing: when the subject carries a routing tag |
| 68 | * (#support/#billing/#technical/#self-hosting) the row becomes a support |
| 69 | * ticket — the primary tag's code (SUP/BIL/TEC/SLF) is stamped, a daily |
| 70 | * per-code ticket_number is allocated, and status starts at 'no_response'. |
| 71 | * The number allocation + the row insert run in ONE transaction so a |
| 72 | * generated counter is never wasted or duplicated on a failed insert. |
| 73 | */ |
| 74 | export async function createContactMessage( |
| 75 | input: CreateContactMessageInput, |
| 76 | ): Promise<CreateContactMessageResult> { |
| 77 | const name = trimWithCap(input.name, NAME_CAP, 'name'); |
| 78 | if (!name) throw new ValidationError('name is required'); |
| 79 | const email = (input.email ?? '').trim(); |
| 80 | if (!email) throw new ValidationError('email is required'); |
| 81 | assertEmail(email); |
| 82 | assertTopic(input.topic); |
| 83 | // Capture the narrowed topic in a const — the assertion narrows the |
| 84 | // mutable `input.topic` property, but that narrowing is lost inside the |
| 85 | // transaction closure below, so hold it in a local that stays typed. |
| 86 | const topic: ContactTopic = input.topic; |
| 87 | const message = trimWithCap(input.message, MESSAGE_CAP, 'message'); |
| 88 | if (!message) throw new ValidationError('message is required'); |
| 89 | // Optional fields — cap + normalise empties to null so we never store |
| 90 | // an empty string for "no subject" / "country unknown". |
| 91 | const subjectTrimmed = trimWithCap(input.subject, SUBJECT_CAP, 'subject'); |
| 92 | const subject = subjectTrimmed.length > 0 ? subjectTrimmed : null; |
| 93 | const countryTrimmed = trimWithCap(input.country, COUNTRY_CAP, 'country'); |
| 94 | const country = countryTrimmed.length > 0 ? countryTrimmed : null; |
| 95 | |
| 96 | const db = getDb(); |
| 97 | const topicCode = primaryTopicCode(subject); |
| 98 | |
| 99 | // Non-ticket path — plain contact message, current behavior. |
| 100 | if (!topicCode) { |
| 101 | const [row] = await db |
| 102 | .insert(contactMessages) |
| 103 | .values({ |
| 104 | id: newId('ctc'), |
| 105 | name, |
| 106 | email, |
| 107 | topic, |
| 108 | subject, |
| 109 | message, |
| 110 | country, |
| 111 | ipHash: input.ipHash ?? null, |
| 112 | userAgent: input.userAgent ?? null, |
| 113 | }) |
| 114 | .returning({ id: contactMessages.id }); |
| 115 | if (!row) throw new Error('insert returned no row'); |
| 116 | return { id: row.id, ticketNumber: null }; |
| 117 | } |
| 118 | |
| 119 | // Ticket path — allocate the number + insert the row in ONE transaction. |
| 120 | const now = new Date(); |
| 121 | return db.transaction(async (tx) => { |
| 122 | const ticketNumber = await generateTicketNumber(topicCode, now, tx); |
| 123 | const [row] = await tx |
| 124 | .insert(contactMessages) |
| 125 | .values({ |
| 126 | id: newId('ctc'), |
| 127 | name, |
| 128 | email, |
| 129 | topic, |
| 130 | subject, |
| 131 | message, |
| 132 | country, |
| 133 | ipHash: input.ipHash ?? null, |
| 134 | userAgent: input.userAgent ?? null, |
| 135 | status: 'no_response', |
| 136 | ticketNumber, |
| 137 | topicCode, |
| 138 | }) |
| 139 | .returning({ id: contactMessages.id }); |
| 140 | if (!row) throw new Error('insert returned no row'); |
| 141 | return { id: row.id, ticketNumber }; |
| 142 | }); |
| 143 | } |