contact.ts143 lines · main
1import { newId, ValidationError } from '@briven/shared';
2
3import { getDb } from '../db/client.js';
4import { contactMessages, contactTopics, type ContactTopic } from '../db/schema.js';
5import { generateTicketNumber, primaryTopicCode } from './support-tickets.js';
6
7const NAME_CAP = 200;
8const EMAIL_CAP = 320;
9const SUBJECT_CAP = 200;
10const COUNTRY_CAP = 100;
11const MESSAGE_CAP = 8_000;
12
13export 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
26function 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
32function 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
40function 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
49export 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 */
74export 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}