migration-requests.ts280 lines · main
1import { newId, NotFoundError, ValidationError } from '@briven/shared';
2import { desc, eq, notInArray } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import {
6 migrationRequests,
7 migrationSources,
8 migrationStatuses,
9 migrationUrgencies,
10 users,
11 type MigrationRequest,
12 type MigrationSource,
13 type MigrationStatus,
14 type MigrationUrgency,
15} from '../db/schema.js';
16
17const SOURCE_NOTES_CAP = 8_000;
18const OPERATOR_NOTES_CAP = 20_000;
19const URL_CAP = 2_000;
20const EMAIL_CAP = 320;
21
22export interface CreateMigrationRequestInput {
23 // Null for unauthenticated leads submitted via the /migrate marketing
24 // form. Operator can patch a user_id later from the admin triage row.
25 userId: string | null;
26 orgId?: string | null;
27 source: string;
28 sourceUrl?: string | null;
29 sourceNotes?: string;
30 estimatedTables?: number | null;
31 estimatedRows?: bigint | null;
32 estimatedFunctions?: number | null;
33 urgency?: string;
34 contactEmail: string;
35}
36
37function assertSource(s: string): asserts s is MigrationSource {
38 if (!(migrationSources as readonly string[]).includes(s)) {
39 throw new ValidationError(
40 `source must be one of: ${migrationSources.join(', ')}`,
41 );
42 }
43}
44
45function assertUrgency(u: string): asserts u is MigrationUrgency {
46 if (!(migrationUrgencies as readonly string[]).includes(u)) {
47 throw new ValidationError(
48 `urgency must be one of: ${migrationUrgencies.join(', ')}`,
49 );
50 }
51}
52
53function assertStatus(s: string): asserts s is MigrationStatus {
54 if (!(migrationStatuses as readonly string[]).includes(s)) {
55 throw new ValidationError(
56 `status must be one of: ${migrationStatuses.join(', ')}`,
57 );
58 }
59}
60
61function assertNonNegativeInt(value: number | null | undefined, field: string): void {
62 if (value == null) return;
63 if (!Number.isInteger(value) || value < 0) {
64 throw new ValidationError(`${field} must be a non-negative integer`);
65 }
66}
67
68function assertNonNegativeBigint(value: bigint | null | undefined, field: string): void {
69 if (value == null) return;
70 if (value < 0n) {
71 throw new ValidationError(`${field} must be non-negative`);
72 }
73}
74
75function trimWithCap(s: string | undefined | null, cap: number, field: string): string {
76 const trimmed = (s ?? '').trim();
77 if (trimmed.length > cap) {
78 throw new ValidationError(`${field} exceeds ${cap}-character cap`);
79 }
80 return trimmed;
81}
82
83function assertEmail(email: string): void {
84 if (email.length > EMAIL_CAP) {
85 throw new ValidationError(`contact email exceeds ${EMAIL_CAP}-character cap`);
86 }
87 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
88 throw new ValidationError('contact email is not a valid address');
89 }
90}
91
92export async function createMigrationRequest(
93 input: CreateMigrationRequestInput,
94): Promise<MigrationRequest> {
95 assertSource(input.source);
96 const urgency = input.urgency ?? 'exploring';
97 assertUrgency(urgency);
98 const contactEmail = (input.contactEmail ?? '').trim();
99 if (!contactEmail) throw new ValidationError('contact email is required');
100 assertEmail(contactEmail);
101 const sourceUrl = trimWithCap(input.sourceUrl, URL_CAP, 'source url') || null;
102 const sourceNotes = trimWithCap(input.sourceNotes, SOURCE_NOTES_CAP, 'source notes');
103 assertNonNegativeInt(input.estimatedTables, 'estimated tables');
104 assertNonNegativeInt(input.estimatedFunctions, 'estimated functions');
105 assertNonNegativeBigint(input.estimatedRows, 'estimated rows');
106
107 const db = getDb();
108 const [row] = await db
109 .insert(migrationRequests)
110 .values({
111 id: newId('mig'),
112 userId: input.userId ?? null,
113 orgId: input.orgId ?? null,
114 source: input.source,
115 sourceUrl,
116 sourceNotes,
117 estimatedTables: input.estimatedTables ?? null,
118 estimatedRows: input.estimatedRows ?? null,
119 estimatedFunctions: input.estimatedFunctions ?? null,
120 urgency,
121 contactEmail,
122 status: 'new',
123 })
124 .returning();
125 if (!row) throw new Error('insert returned no row');
126 return row;
127}
128
129export async function listMigrationRequestsForUser(
130 userId: string,
131 opts: { limit?: number } = {},
132): Promise<MigrationRequest[]> {
133 const db = getDb();
134 return db
135 .select()
136 .from(migrationRequests)
137 .where(eq(migrationRequests.userId, userId))
138 .orderBy(desc(migrationRequests.createdAt))
139 .limit(Math.min(100, Math.max(1, opts.limit ?? 50)));
140}
141
142export async function listMigrationRequestsForAdmin(
143 opts: { limit?: number; openOnly?: boolean } = {},
144): Promise<MigrationRequest[]> {
145 const db = getDb();
146 const limit = Math.min(200, Math.max(1, opts.limit ?? 100));
147 if (opts.openOnly) {
148 return db
149 .select()
150 .from(migrationRequests)
151 .where(notInArray(migrationRequests.status, ['completed', 'cancelled']))
152 .orderBy(desc(migrationRequests.createdAt))
153 .limit(limit);
154 }
155 return db
156 .select()
157 .from(migrationRequests)
158 .orderBy(desc(migrationRequests.createdAt))
159 .limit(limit);
160}
161
162/**
163 * Hard-delete a migration request the caller owns. Cascades to the
164 * request's audit-event rows via the FK. Returns `false` when the row
165 * doesn't exist or belongs to a different user — same shape for both so
166 * the endpoint never leaks which case applied.
167 */
168export async function deleteMigrationRequestForUser(
169 id: string,
170 userId: string,
171): Promise<boolean> {
172 const db = getDb();
173 const rows = await db
174 .select()
175 .from(migrationRequests)
176 .where(eq(migrationRequests.id, id))
177 .limit(1);
178 const row = rows[0];
179 if (!row) return false;
180 if (row.userId !== userId) return false;
181 await db.delete(migrationRequests).where(eq(migrationRequests.id, id));
182 return true;
183}
184
185export async function getMigrationRequest(id: string): Promise<MigrationRequest> {
186 const db = getDb();
187 const rows = await db
188 .select()
189 .from(migrationRequests)
190 .where(eq(migrationRequests.id, id))
191 .limit(1);
192 const row = rows[0];
193 if (!row) throw new NotFoundError('migration_request', id);
194 return row;
195}
196
197export interface UpdateMigrationRequestInput {
198 status?: string;
199 assignedTo?: string | null;
200 operatorNotes?: string;
201}
202
203/**
204 * Operator-driven: link an unauth lead to an existing briven user.
205 * Used when the customer who left a /migrate lead has since (or
206 * already) signed up under the same email — the operator clicks
207 * "promote to user" in the admin row and the request's user_id is
208 * patched. Idempotent: a request already linked to the same user
209 * is a no-op.
210 */
211export async function promoteMigrationRequestToUser(
212 requestId: string,
213 contactEmail: string,
214): Promise<{ request: MigrationRequest; linkedUserId: string | null }> {
215 const db = getDb();
216 const request = await getMigrationRequest(requestId);
217 // Resolve by the request's own contactEmail (admin doesn't pass user
218 // id — they pass intent) so we never link to the wrong account.
219 // contactEmail param is the recorded one, passed by the caller for
220 // explicitness in the audit log.
221 if (request.contactEmail.toLowerCase() !== contactEmail.toLowerCase()) {
222 throw new ValidationError(
223 'contact email mismatch — refusing to promote to a user who is not the lead',
224 );
225 }
226 const candidates = await db
227 .select({ id: users.id })
228 .from(users)
229 .where(eq(users.email, contactEmail.toLowerCase()))
230 .limit(1);
231 const candidate = candidates[0];
232 if (!candidate) {
233 return { request, linkedUserId: null };
234 }
235 if (request.userId === candidate.id) {
236 return { request, linkedUserId: candidate.id };
237 }
238 const [updated] = await db
239 .update(migrationRequests)
240 .set({ userId: candidate.id, updatedAt: new Date() })
241 .where(eq(migrationRequests.id, requestId))
242 .returning();
243 if (!updated) throw new NotFoundError('migration_request', requestId);
244 return { request: updated, linkedUserId: candidate.id };
245}
246
247export async function updateMigrationRequest(
248 id: string,
249 input: UpdateMigrationRequestInput,
250): Promise<MigrationRequest> {
251 const patch: {
252 status?: MigrationStatus;
253 assignedTo?: string | null;
254 operatorNotes?: string;
255 updatedAt: Date;
256 } = { updatedAt: new Date() };
257 if (input.status !== undefined) {
258 assertStatus(input.status);
259 patch.status = input.status;
260 }
261 if (input.assignedTo !== undefined) {
262 patch.assignedTo = input.assignedTo === '' ? null : input.assignedTo;
263 }
264 if (input.operatorNotes !== undefined) {
265 patch.operatorNotes = trimWithCap(
266 input.operatorNotes,
267 OPERATOR_NOTES_CAP,
268 'operator notes',
269 );
270 }
271
272 const db = getDb();
273 const [row] = await db
274 .update(migrationRequests)
275 .set(patch)
276 .where(eq(migrationRequests.id, id))
277 .returning();
278 if (!row) throw new NotFoundError('migration_request', id);
279 return row;
280}