1
21import { Inject, Injectable } from "@nestjs/common";
22import type { Request } from "express";
23import { Prisma } from "@prisma/client";
24import { PrismaService } from "../../common/prisma.service.js";
25import { AuditService } from "../../common/audit.service.js";
26import { ApiError, type ErrorDetail } from "../../common/errors.js";
27import { assertCan } from "../../lib/authorize.js";
28import { between } from "../../lib/positions.js";
29import {
30 FORM_CONFIG_SCHEMA,
31 FORM_STATUSES,
32 isCompatibleMapping,
33 type FormQuestionType,
34 type FormStatus,
35 type DbPropertyType
36} from "@factory/shared";
37import { nextFreeSlug, normalizeSlug, isSlugConstraintError, SLUG_INPUT_REGEX } from "./slug.js";
38import type { CreateFormInput, UpdateFormInput } from "./form.dto.js";
39
40
41export const FORM_STATUS_TRANSITIONS: Record<FormStatus, readonly FormStatus[]> = {
42 DRAFT: ["PUBLISHED"],
43 PUBLISHED: ["CLOSED"],
44 CLOSED: ["PUBLISHED"]
45};
46
47interface InlineQuestion {
48 type: string;
49 label: string;
50 description?: string;
51 required?: boolean;
52 validation?: unknown;
53 propertyId?: string | null;
54 placeholder?: string;
55 position?: string;
56}
57
58@Injectable()
59export class FormsService {
60 constructor(
61 @Inject(PrismaService) private readonly prisma: PrismaService,
62 @Inject(AuditService) private readonly audit: AuditService
63 ) {}
64
65 private readonly formInclude = {
66 database: { select: { id: true, title: true } },
67 questions: { orderBy: { position: "asc" } },
68 _count: { select: { submissions: true } }
69 } as const;
70
71
72
73
75 private async loadRowChecked(formId: string, userId: string, need: "VIEW" | "EDIT") {
76 const form = await this.prisma.form.findUnique({ where: { id: formId } });
77 if (!form) throw ApiError.notFound("Form not found.");
78 await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, need);
79 return form;
80 }
81
82
83
84
85 async listByDatabase(databaseId: string, userId: string) {
86 const db = await this.prisma.database.findUnique({ where: { id: databaseId } });
87 if (!db) throw ApiError.notFound("Database not found.");
88 await assertCan(this.prisma, { workspaceId: db.workspaceId, userId, resource: { databaseId: db.id } }, "VIEW");
89 const forms = await this.prisma.form.findMany({
90 where: { databaseId },
91 include: { _count: { select: { questions: true, submissions: true } } },
92 orderBy: { createdAt: "asc" }
93 });
94 return {
95 data: forms.map((f) => ({
96 id: f.id,
97 databaseId: f.databaseId,
98 name: f.name,
99 slug: f.slug,
100 description: f.description,
101 status: f.status,
102 config: f.config,
103 questionCount: f._count.questions,
104 submissionCount: f._count.submissions,
105 createdById: f.createdById,
106 createdAt: f.createdAt,
107 updatedAt: f.updatedAt
108 }))
109 };
110 }
111
112
113 async create(databaseId: string, userId: string, input: CreateFormInput, req: Request) {
114 const db = await this.prisma.database.findUnique({ where: { id: databaseId } });
115 if (!db) throw ApiError.notFound("Database not found.");
116 await assertCan(this.prisma, { workspaceId: db.workspaceId, userId, resource: { databaseId: db.id } }, "EDIT");
117
118 const config = this.parseConfig(input.config);
119 const slug = await this.generateSlug(input.name, input.slug);
120
121 let form;
122 try {
123 form = await this.prisma.$transaction(async (tx) => {
124 const created = await tx.form.create({
125 data: {
126 workspaceId: db.workspaceId,
127 databaseId: db.id,
128 name: input.name,
129 slug,
130 description: input.description ?? null,
131 config: config as object,
132 createdById: userId
133 }
134 });
135 if (input.questions) {
136 await this.insertQuestions(tx, created.id, input.questions);
137 }
138 return tx.form.findUniqueOrThrow({ where: { id: created.id }, include: this.formInclude });
139 });
140 } catch (err) {
141 if (isSlugConstraintError(err)) {
142 throw new ApiError("CONFLICT", 409, "Form slug already exists.", [{ field: "slug", issue: "already_exists" }]);
143 }
144 throw err;
145 }
146
147 await this.audit.audit(req, req.auth, {
148 action: "form.created",
149 resourceType: "form",
150 resourceId: (form as { id: string }).id,
151 workspaceId: db.workspaceId,
152 metadata: { name: input.name, slug, databaseId, questionCount: input.questions?.length ?? 0 }
153 });
154 return form;
155 }
156
157
158 async get(formId: string, userId: string) {
159 const form = await this.loadRowChecked(formId, userId, "VIEW");
160 const full = await this.prisma.form.findUniqueOrThrow({
161 where: { id: formId },
162 include: this.formInclude
163 });
164 const properties = await this.prisma.databaseProperty.findMany({
165 where: { databaseId: form.databaseId, deletedAt: null },
166 orderBy: { position: "asc" },
167 select: { id: true, name: true, type: true, isTitle: true }
168 });
169 return { ...full, properties };
170 }
171
172
178 async update(formId: string, userId: string, input: UpdateFormInput, req: Request) {
179 const form = await this.loadRowChecked(formId, userId, "EDIT");
180 const db = await this.prisma.database.findFirst({
181 where: { id: form.databaseId, workspaceId: form.workspaceId }
182 });
183 if (!db) throw ApiError.notFound("Form's database not found in this workspace.");
184
185
186 if (input.status !== undefined && input.status !== form.status) {
187 const allowed = FORM_STATUS_TRANSITIONS[form.status];
188 if (!allowed.includes(input.status)) {
189 throw new ApiError("UNPROCESSABLE_ENTITY", 422, `Cannot transition form from ${form.status} to ${input.status}.`, [
190 { field: "status", issue: "invalid_transition", allowed: [...allowed] }
191 ]);
192 }
193 if (input.status === "PUBLISHED") {
194 await this.assertPublishable(form);
195 }
196 }
197
198
199 if (input.slug !== undefined && input.slug !== form.slug) {
200 if (!SLUG_INPUT_REGEX.test(input.slug)) {
201 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "slug must be kebab-case (a-z, 0-9, single dashes).", [
202 { field: "slug", issue: "invalid_format" }
203 ]);
204 }
205 const taken = await this.prisma.form.findUnique({ where: { slug: input.slug } });
206 if (taken) {
207 throw new ApiError("CONFLICT", 409, "Form slug already exists.", [{ field: "slug", issue: "already_exists" }]);
208 }
209 }
210
211 const config = input.config !== undefined ? this.parseConfig(input.config) : undefined;
212
213 const updated = await this.prisma.$transaction(async (tx) => {
214 await tx.form.update({
215 where: { id: formId },
216 data: {
217 ...(input.name !== undefined ? { name: input.name } : {}),
218 ...(input.description !== undefined ? { description: input.description } : {}),
219 ...(input.status !== undefined ? { status: input.status } : {}),
220 ...(input.slug !== undefined ? { slug: input.slug } : {}),
221 ...(config !== undefined ? { config: config as object } : {})
222 }
223 });
224 if (input.questions) {
225 await tx.formQuestion.deleteMany({ where: { formId } });
226 await this.insertQuestions(tx, formId, input.questions);
227 }
228 return tx.form.findUniqueOrThrow({ where: { id: formId }, include: this.formInclude });
229 });
230
231 const auditMeta: Record<string, unknown> = { fields: Object.keys(input) };
232 if (input.status !== undefined && input.status !== form.status) {
233 auditMeta.status = { from: form.status, to: input.status };
234 }
235 const action =
236 input.status === "PUBLISHED" && form.status !== "PUBLISHED" ? "form.published" : "form.updated";
237 await this.audit.audit(req, req.auth, {
238 action,
239 resourceType: "form",
240 resourceId: formId,
241 workspaceId: form.workspaceId,
242 metadata: auditMeta
243 });
244 return updated;
245 }
246
247
252 async remove(formId: string, userId: string, req: Request) {
253 const form = await this.loadRowChecked(formId, userId, "EDIT");
254 const suffix = `-deleted-${form.id.replace(/[^a-z0-9]/gi, "").slice(-8)}`;
255 const newSlug = `${form.slug.slice(0, 50 - suffix.length)}${suffix}`;
256 await this.prisma.form.update({
257 where: { id: formId },
258 data: { status: "DRAFT", slug: newSlug }
259 });
260 await this.audit.audit(req, req.auth, {
261 action: "form.deleted",
262 resourceType: "form",
263 resourceId: formId,
264 workspaceId: form.workspaceId,
265 metadata: { name: form.name, previousSlug: form.slug, newSlug }
266 });
267 return { deleted: true };
268 }
269
270
271
272
279 async assertPublishable(form: { id: string; databaseId: string; workspaceId: string; slug: string }): Promise<void> {
280 const issues: ErrorDetail[] = [];
281
282 const db = await this.prisma.database.findFirst({
283 where: { id: form.databaseId, workspaceId: form.workspaceId },
284 select: { id: true, isArchived: true }
285 });
286 if (!db) {
287 issues.push({ field: "database", issue: "database_missing", message: "the form's database no longer exists" });
288 } else if (db.isArchived) {
289 issues.push({ field: "database", issue: "database_archived", message: "the form's database is in the trash" });
290 }
291
292 const [questions, slugTaken] = await Promise.all([
293 this.prisma.formQuestion.findMany({ where: { formId: form.id } }),
294 this.prisma.form.count({ where: { slug: form.slug, NOT: { id: form.id } } })
295 ]);
296 if (questions.length === 0) {
297 issues.push({
298 field: "questions",
299 issue: "NO_QUESTIONS",
300 message: "a form needs at least one question before publishing"
301 });
302 }
303
304 const props = db
305 ? await this.prisma.databaseProperty.findMany({
306 where: { databaseId: db.id, deletedAt: null },
307 select: { id: true, name: true, type: true }
308 })
309 : [];
310 const byId = new Map(props.map((p) => [p.id, p]));
311 for (const q of questions) {
312 if (!q.propertyId) continue;
313 const prop = byId.get(q.propertyId);
314 if (!prop) {
315 issues.push({
316 field: `questions:${q.id}`,
317 issue: "UNKNOWN_PROPERTY",
318 message: `question "${q.label}" maps to a property that no longer exists`
319 });
320 } else if (!isCompatibleMapping(q.type as FormQuestionType, prop.type as DbPropertyType)) {
321 issues.push({
322 field: `questions:${q.id}`,
323 issue: "PROPERTY_TYPE_MISMATCH",
324 message: `question "${q.label}" (${q.type}) maps to an incompatible ${prop.type} property`
325 });
326 }
327 }
328
329 if (slugTaken > 0) {
330 issues.push({ field: "slug", issue: "SLUG_TAKEN", message: "the slug is no longer unique" });
331 }
332
333 if (issues.length > 0) {
334 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Form cannot be published.", issues);
335 }
336 }
337
338
339
340
341 private async insertQuestions(
342 tx: Prisma.TransactionClient,
343 formId: string,
344 questions: InlineQuestion[]
345 ): Promise<void> {
346 let cursor: string | undefined;
347 for (const q of questions) {
348 const position = q.position ?? between(cursor);
349 cursor = position;
350 await tx.formQuestion.create({
351 data: {
352 formId,
353 label: q.label,
354 description: q.description ?? null,
355 type: q.type as never,
356 required: q.required ?? false,
357 validation: (q.validation ?? {}) as object,
358 propertyId: q.propertyId ?? null,
359 placeholder: q.placeholder ?? null,
360 position
361 }
362 });
363 }
364 }
365
366
367 private parseConfig(config: unknown): Record<string, unknown> {
368 const parsed = FORM_CONFIG_SCHEMA.safeParse(config ?? {});
369 if (!parsed.success) {
370 const first = parsed.error.issues[0];
371 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Form config is invalid.", [
372 {
373 field: `config${first && first.path.length > 0 ? `.${first.path.join(".")}` : ""}`,
374 issue: first?.code ?? "invalid",
375 message: first?.message
376 }
377 ]);
378 }
379 return parsed.data as Record<string, unknown>;
380 }
381
382
383 private async generateSlug(name: string, provided?: string): Promise<string> {
384 if (provided) {
385
386
387
388
389
390 if (!SLUG_INPUT_REGEX.test(provided)) {
391 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "slug must be kebab-case (a-z, 0-9, single dashes).", [
392 { field: "slug", issue: "invalid_format" }
393 ]);
394 }
395 return normalizeSlug(provided);
396 }
397
398
399
400 const base = normalizeSlug(name) || (await this.randomSlugBase());
401 const taken = async (slugs: string[]) =>
402 new Set(
403 (await this.prisma.form.findMany({ where: { slug: { in: slugs } }, select: { slug: true } })).map(
404 (f) => f.slug
405 )
406 );
407 return nextFreeSlug(base, taken);
408 }
409
410 private async randomSlugBase(): Promise<string> {
411 const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
412 let out = "form";
413 for (let i = 0; i < 6; i += 1) out += chars[Math.floor(Math.random() * chars.length)];
414 return out;
415 }
416}
417
418export { FORM_STATUSES };
419export type { FormStatus };
420