1
47import { Inject, Injectable } from "@nestjs/common";
48import type { Request } from "express";
49import { randomBytes } from "node:crypto";
50import {
51 FORM_CONFIG_SCHEMA,
52 FORM_ACCENTS,
53 FORM_ERROR_CODES,
54 toPublicSafeValidation,
55 type FormQuestionType,
56 type FormStatus
57} from "@factory/shared";
58import { Prisma } from "@prisma/client";
59import { buildFileKey, isAllowedMime, presignGet, presignPut, sanitizeFilename, sniffVerdict } from "@factory/storage";
60import { jobTraceMetadata } from "../../common/trace-metadata.js";
61import { PrismaService } from "../../common/prisma.service.js";
62import { AuditService } from "../../common/audit.service.js";
63import { EventBus } from "../../common/event-bus.js";
64import { RedisService } from "../../common/redis.service.js";
65import { logger } from "../../common/logger.js";
66import { ApiError, type ErrorDetail } from "../../common/errors.js";
67import { EVENTS } from "../../constants.js";
68import { between } from "../../lib/positions.js";
69import { loadEnv } from "../../config/env.js";
70import { effectiveStorageQuota } from "../files/quota.js";
71import { effectiveMaxFileSize } from "../files/uploads.service.js";
72import { filesBucketConfig, makeFilesClient, signExpirySeconds } from "../files/s3.js";
73import { renderIntroMarkdown } from "./markdown.js";
74import { FormRateLimiter } from "./rate-limit.js";
75import { verifyRecaptcha } from "./recaptcha.js";
76import { buildAnswerResult, configIntegrityIssue, type StoredQuestion } from "./submit-schema.js";
77import { NotificationsService } from "../notifications/notifications.service.js";
78
79
80const HONEYPOT_FIELDS = ["website", "hp"] as const;
81
82
83const MAX_ANSWERS_BYTES = 64 * 1024;
84
85
86interface PublicUploadInput {
87 name: string;
88 size: number;
89 mimeType: string;
90}
91
92@Injectable()
93export class PublicFormsService {
94 constructor(
95 @Inject(PrismaService) private readonly prisma: PrismaService,
96 @Inject(AuditService) private readonly audit: AuditService,
97 @Inject(EventBus) private readonly events: EventBus,
98 @Inject(NotificationsService) private readonly notifications: NotificationsService,
99 @Inject(FormRateLimiter) private readonly rateLimiter: FormRateLimiter,
100 @Inject(RedisService) private readonly redis: RedisService
101 ) {}
102
103
104
105
106 async getPublic(slug: string) {
107 const form = await this.prisma.form.findUnique({
108 where: { slug },
109 include: { questions: { orderBy: { position: "asc" } } }
110 });
111
112
113 if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found.");
114
115 const config = this.safeConfig(form.config);
116 const status = form.status as FormStatus;
117 const published = status === "PUBLISHED";
118
119
120
121
122 return {
123 form: {
124 slug: form.slug,
125 name: form.name,
126 description: form.description,
127 status: published ? "published" : "closed",
128 config: {
129 iconUrl: published ? await this.signedIconUrl(form, config.icon) : null,
130 intro: renderIntroMarkdown(typeof config.intro === "string" ? config.intro : ""),
131 accent: config.accent ?? FORM_ACCENTS[0],
132 successMessage: config.successMessage ?? null,
133 closedMessage: config.closedMessage ?? null,
134 poweredBy: config.poweredBy ?? true,
135 recaptcha: { enabled: Boolean((config.recaptcha as { enabled?: boolean } | undefined)?.enabled) }
136 }
137 },
138 questions: form.questions.map((q) => this.publicQuestion(q, published))
139 };
140 }
141
142
144 private publicQuestion(
145 q: {
146 id: string;
147 label: string;
148 description: string | null;
149 type: string;
150 required: boolean;
151 validation: unknown;
152 placeholder: string | null;
153 },
154 published: boolean
155 ) {
156 return {
157 id: q.id,
158 type: q.type as FormQuestionType,
159 label: q.label,
160 description: q.description,
161 required: q.required,
162 placeholder: q.placeholder,
163 validation: published ? toPublicSafeValidation(q.validation) : {}
164 };
165 }
166
167
169 private safeConfig(config: unknown): Record<string, unknown> {
170 const parsed = FORM_CONFIG_SCHEMA.safeParse(config ?? {});
171 return parsed.success ? (parsed.data as Record<string, unknown>) : {};
172 }
173
174
175 private async signedIconUrl(
176 form: { workspaceId: string },
177 icon: unknown
178 ): Promise<string | null> {
179 if (typeof icon !== "string" || icon.length === 0) return null;
180 const file = await this.prisma.file.findFirst({
181 where: { id: icon, workspaceId: form.workspaceId, deletedAt: null },
182 select: { id: true, status: true, key: true, mimeType: true }
183 });
184 if (!file || file.status !== "READY" || !file.mimeType.startsWith("image/")) return null;
185 try {
186 const env = loadEnv();
187 const { url } = presignGet({
188 config: filesBucketConfig(env),
189 key: file.key,
190 expiresSec: 900,
191 now: new Date()
192 });
193 return url;
194 } catch {
195 return null;
196 }
197 }
198
199
200
201
205 async submitPublic(
206 slug: string,
207 body: { answers?: unknown; website?: string; hp?: string; recaptchaToken?: string },
208 req: Request
209 ): Promise<{ success: true; submissionId: string; message: string | null }> {
210
211
212 const contentType = String(req.headers["content-type"] ?? "");
213 if (!contentType.includes("application/json")) {
214 throw new ApiError("UNSUPPORTED_MEDIA_TYPE", 415, "Content-Type must be application/json.", [
215 { issue: "json_required" }
216 ]);
217 }
218
219 const form = await this.prisma.form.findUnique({
220 where: { slug },
221 include: { questions: { orderBy: { position: "asc" } } }
222 });
223
224 if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found.");
225 if (form.status === "CLOSED") {
226 throw new ApiError("FORBIDDEN", 403, "This form is closed.", [
227 { reason: FORM_ERROR_CODES.FORM_CLOSED }
228 ]);
229 }
230
231
232 const answersJson = JSON.stringify(body.answers ?? {});
233 if (answersJson.length > MAX_ANSWERS_BYTES) {
234 throw new ApiError("PAYLOAD_TOO_LARGE", 413, "The answers payload is too large.", [
235 { issue: FORM_ERROR_CODES.PAYLOAD_TOO_LARGE, maxBytes: MAX_ANSWERS_BYTES }
236 ]);
237 }
238
239
240
241 if (HONEYPOT_FIELDS.some((f) => body[f] && String(body[f]).trim() !== "")) {
242 logger.warn(`form.honeypot_triggered slug=${slug} ip=${req.ip ?? "unknown"}`);
243 throw new ApiError("VALIDATION_ERROR", 400, "Submission failed. Please try again.");
244 }
245
246 const ip = req.ip ?? "unknown";
247
248
249 await verifyRecaptcha(body.recaptchaToken, ip);
250
251
252 const rl = await this.rateLimiter.check(slug, ip);
253 if (!rl.allowed) {
254
255
256
257 req.res?.setHeader("Retry-After", String(rl.retryAfterSeconds));
258 throw new ApiError("RATE_LIMITED", 429, "Too many submissions. Try again shortly.", [
259 { retryAfterSeconds: rl.retryAfterSeconds }
260 ]);
261 }
262
263
264
265 const submittedBy = await this.optionalSession(req, form.workspaceId);
266 const anonymous = submittedBy === null;
267
268
269 const stored: StoredQuestion[] = form.questions.map((q) => ({
270 id: q.id,
271 label: q.label,
272 type: q.type,
273 required: q.required,
274 validation: q.validation
275 }));
276 const configIssue = configIntegrityIssue(stored);
277 if (configIssue) {
278 throw new ApiError("INTERNAL_ERROR", 500, "Form configuration is corrupt.", [
279 { reason: FORM_ERROR_CODES.FORM_CONFIG_INVALID, detail: configIssue }
280 ]);
281 }
282 const { answers, violations } = buildAnswerResult(
283 stored,
284 (body.answers ?? {}) as Record<string, unknown>
285 );
286 if (violations.length > 0) {
287 const details: ErrorDetail[] = violations.map((v) => ({
288 questionId: v.questionId,
289 code: v.code,
290 message: v.message
291 }));
292 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Submission failed validation.", details);
293 }
294
295
296
297
298
299 const fileIds = collectFileIds(answers);
300 const fileRows = new Map<string, { status: string; metadata: unknown }>();
301 if (fileIds.length > 0) {
302 const files = await this.prisma.file.findMany({
303 where: { id: { in: fileIds }, workspaceId: form.workspaceId, deletedAt: null },
304 select: { id: true, status: true, metadata: true }
305 });
306 for (const f of files) fileRows.set(f.id, { status: f.status, metadata: f.metadata });
307 for (const id of fileIds) {
308 const f = fileRows.get(id);
309 const meta = (f?.metadata ?? {}) as Record<string, unknown>;
310 if (!f || meta.formId !== form.id) {
311 logger.warn(`form.file_not_owned slug=${slug} fileId=${id} workspaceId=${form.workspaceId}`);
312 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "A submitted file is not available in this form.", [
313 { fileId: id, reason: FORM_ERROR_CODES.FILE_NOT_OWNED }
314 ]);
315 }
316 if (f.status !== "READY") {
317 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "A submitted file is still processing — try again in a moment.", [
318 { fileId: id, reason: FORM_ERROR_CODES.FILE_NOT_READY }
319 ]);
320 }
321 }
322 }
323
324
325
326
327
328
329
330 const properties = await this.prisma.databaseProperty.findMany({
331 where: { databaseId: form.databaseId, deletedAt: null },
332 select: { id: true, type: true, isTitle: true }
333 });
334 const propById = new Map(properties.map((p) => [p.id, p]));
335 const recordValues = new Map<string, unknown>();
336 let titlePropertyId: string | null = null;
337 let mappedCount = 0;
338 for (const q of form.questions) {
339 const prop = q.propertyId ? propById.get(q.propertyId) : undefined;
340 if (!prop) continue;
341 mappedCount += 1;
342 if (prop.isTitle || prop.type === "TITLE") titlePropertyId = prop.id;
343 if (!(q.id in answers)) continue;
344 recordValues.set(prop.id, coerceForProperty(answers[q.id], prop.type));
345 }
346 const mapped = { length: mappedCount };
347
348
349
350 const titleAnswer =
351 titlePropertyId !== null
352 ? (answers[form.questions.find((q) => q.propertyId === titlePropertyId)?.id ?? ""] ?? undefined)
353 : undefined;
354 const title =
355 typeof titleAnswer === "string" && titleAnswer.trim() !== ""
356 ? titleAnswer.trim().slice(0, 300)
357 : fallbackTitle(form.name, answers);
358
359
360
361 const createdBy = submittedBy ?? form.createdById;
362 const { submission, recordId, pageId } = await this.prisma.$transaction(async (tx) => {
363 let recordIdOut: string | null = null;
364 let pageIdOut: string | null = null;
365 if (mapped.length > 0) {
366 const last = await tx.databaseRecord.findFirst({
367 where: { databaseId: form.databaseId },
368 orderBy: { position: "desc" },
369 select: { position: true }
370 });
371 const position = between(last?.position, undefined);
372 const page = await tx.page.create({
373 data: {
374 workspaceId: form.workspaceId,
375 title,
376 position,
377 createdBy,
378 updatedBy: createdBy
379 }
380 });
381 const record = await tx.databaseRecord.create({
382 data: {
383 workspaceId: form.workspaceId,
384 databaseId: form.databaseId,
385 pageId: page.id,
386 position,
387 createdBy,
388 lastEditedBy: createdBy
389 }
390 });
391 recordIdOut = record.id;
392 pageIdOut = page.id;
393 for (const [propertyId, value] of recordValues) {
394 const json = value === null ? Prisma.JsonNull : (value as Prisma.InputJsonValue);
395 await tx.recordProperty.upsert({
396 where: { recordId_propertyId: { recordId: record.id, propertyId } },
397 update: { value: json },
398 create: { recordId: record.id, propertyId, value: json }
399 });
400 }
401 }
402
403 const sub = await tx.formSubmission.create({
404 data: {
405 workspaceId: form.workspaceId,
406 formId: form.id,
407 recordId: recordIdOut,
408 answers: answers as object,
409 submittedBy: submittedBy ?? null,
410 ipAddress: ip
411 }
412 });
413
414
415
416
417
418
419 if (fileIds.length > 0) {
420 for (const id of fileIds) {
421 const meta = (fileRows.get(id)?.metadata ?? {}) as Record<string, unknown>;
422 await tx.file.update({
423 where: { id },
424 data: {
425 pageId: pageIdOut,
426 blockId: null,
427 metadata: { ...meta, formId: form.id, submissionId: sub.id }
428 }
429 });
430 }
431 }
432
433 return { submission: sub, recordId: recordIdOut, pageId: pageIdOut };
434 });
435
436
437
438 const redactedIp = redactIp24(ip);
439 await this.events.publish(EVENTS.FORM_SUBMITTED, {
440 workspaceId: form.workspaceId,
441 formId: form.id,
442 slug: form.slug,
443 submissionId: submission.id,
444 recordId,
445 databaseId: form.databaseId,
446 questionCount: form.questions.length,
447 anonymous,
448 ip: redactedIp
449 });
450
451 try {
452 const owner = await this.prisma.user.findUnique({
453 where: { id: form.createdById },
454 select: { displayName: true, name: true }
455 });
456 await this.notifications.notify({
457 workspaceId: form.workspaceId,
458 userId: form.createdById,
459 type: "FORM_SUBMITTED",
460 title: `${form.name} received a new response`,
461 body: submittedBy ? `From ${submittedBy === form.createdById ? "you" : (owner?.displayName ?? owner?.name ?? "a member")}.` : "Anonymous public submission.",
462 link: { type: "form", id: form.id },
463 actorId: submittedBy ?? null
464 });
465 } catch (err) {
466
467 logger.warn(`form owner notification failed (${form.id}): ${(err as Error).message}`);
468 }
469
470 await this.audit.audit(req, undefined, {
471 action: "form.submitted",
472 resourceType: "form_submission",
473 resourceId: submission.id,
474 workspaceId: form.workspaceId,
475 actorId: submittedBy ?? "anonymous",
476 metadata: {
477 formId: form.id,
478 slug: form.slug,
479 recordId,
480 ip,
481 anonymous,
482 questionCount: form.questions.length
483 }
484 });
485
486
487
488 req.res?.setHeader("X-Submission-Id", submission.id);
489 return {
490 success: true,
491 submissionId: submission.id,
492 message: (this.safeConfig(form.config).successMessage as string | undefined) ?? null
493 };
494 }
495
496
497
509 private async optionalSession(req: Request, workspaceId: string): Promise<string | null> {
510 const auth = (req as Request & { auth?: import("../../common/types.js").AuthContext }).auth;
511 if (!auth || auth.kind !== "user" || !auth.userId) return null;
512 const membership = await this.prisma.workspaceMember.findUnique({
513 where: { workspaceId_userId: { workspaceId, userId: auth.userId } }
514 });
515 return membership ? auth.userId : null;
516 }
517
518
519
520
527 async openPublicUpload(slug: string, input: PublicUploadInput, req: Request) {
528 const form = await this.loadPublicForm(slug);
529 const ws = await this.prisma.workspace.findUnique({ where: { id: form.workspaceId } });
530 if (!ws || ws.deletedAt) throw ApiError.notFound("Form not found.");
531
532 if (!isAllowedMime(input.mimeType)) {
533 throw ApiError.validation("File type is not allowed.", [
534 { field: "mimeType", issue: "mime_not_allowed", received: input.mimeType }
535 ]);
536 }
537 const limits = (ws.usageLimits ?? {}) as { maxFileSize?: number; storageQuota?: number };
538 const maxFileSize = effectiveMaxFileSize(ws.billingPlan, limits);
539 if (input.size > maxFileSize) {
540 throw new ApiError("FILE_TOO_LARGE", 413, "File size exceeds the workspace plan limit.", [
541 { maxBytes: maxFileSize, plan: ws.billingPlan }
542 ]);
543 }
544 const quota = effectiveStorageQuota(ws.billingPlan, limits);
545 const used = Number(ws.usageStorageBytes ?? 0n);
546 if (input.size > quota - used) {
547 throw new ApiError("QUOTA_EXCEEDED", 413, "The workspace storage quota is full.", [
548 { maxBytes: quota, usedBytes: used }
549 ]);
550 }
551
552 const env = loadEnv();
553 const fileId = `file_${randomBytes(12).toString("base64url")}`;
554 const key = buildFileKey({
555 workspaceId: form.workspaceId,
556 fileId,
557 version: 1,
558 name: sanitizeFilename(input.name)
559 });
560 const now = new Date();
561 const expiresSec = signExpirySeconds(env);
562
563 const file = await this.prisma.file.create({
564 data: {
565 id: fileId,
566 workspaceId: form.workspaceId,
567 pageId: null,
568 blockId: null,
569 name: sanitizeFilename(input.name),
570 key,
571 size: input.size,
572 mimeType: input.mimeType,
573 status: "UPLOADING",
574 metadata: { formId: form.id, public: true } as object,
575 uploadedBy: "anonymous"
576 }
577 });
578
579 const put = presignPut({
580 config: filesBucketConfig(env),
581 key,
582 contentType: input.mimeType,
583 expiresSec,
584 now
585 });
586
587 await this.audit.audit(req, undefined, {
588 action: "form.public_upload_created",
589 resourceType: "file",
590 resourceId: file.id,
591 workspaceId: form.workspaceId,
592 actorId: "anonymous",
593 metadata: { formId: form.id, name: input.name, size: input.size, mimeType: input.mimeType }
594 });
595
596 return {
597 upload: {
598 fileId: file.id,
599 uploadUrl: put.url,
600 method: "PUT" as const,
601 headers: { "Content-Type": input.mimeType },
602 expiresAt: new Date(now.getTime() + expiresSec * 1000).toISOString()
603 }
604 };
605 }
606
607
613 async completePublicUpload(slug: string, fileId: string, req: Request) {
614 const form = await this.loadPublicForm(slug);
615 const file = await this.prisma.file.findFirst({
616 where: { id: fileId, workspaceId: form.workspaceId, deletedAt: null }
617 });
618 const meta = (file?.metadata ?? {}) as Record<string, unknown>;
619 if (!file || meta.formId !== form.id) throw ApiError.notFound("Upload session not found.");
620 if (file.status !== "UPLOADING") {
621 throw new ApiError("CONFLICT", 409, "This upload session has already been completed.", [
622 { reason: "upload_already_completed" }
623 ]);
624 }
625
626 const s3 = makeFilesClient();
627 let head: { size: number };
628 try {
629 head = await s3.headObject(file.key);
630 } catch (err) {
631 const status = (err as { status?: number })?.status;
632 if (status === 404) {
633 throw new ApiError("UPLOAD_EXPIRED", 410, "The upload object is not present.", [
634 { reason: "upload_expired" }
635 ]);
636 }
637 throw err;
638 }
639 if (head.size !== file.size) {
640 throw new ApiError("UPLOAD_INCOMPLETE", 422, "Uploaded object size does not match the declared size.", [
641 { received: head.size, expected: file.size }
642 ]);
643 }
644
645 const { prefix } = await s3.readPrefix(file.key, 8 * 1024);
646 const verdict = sniffVerdict(prefix, file.mimeType);
647 if (verdict === "mismatch") {
648 await s3.deleteObject(file.key).catch(() => undefined);
649 await this.prisma.file.update({
650 where: { id: file.id },
651 data: {
652 status: "FAILED",
653 metadata: { ...meta, pipeline: { error: "mime_mismatch" } },
654 scanReport: { engine: "magic-bytes", result: "mismatch", claimed: file.mimeType } as object
655 }
656 });
657 throw new ApiError("MIME_MISMATCH", 415, "The uploaded bytes do not match the declared MIME type.", [
658 { claimed: file.mimeType, issue: "mime_mismatch" }
659 ]);
660 }
661
662 await this.prisma.file.update({
663 where: { id: file.id },
664 data: { status: "SCANNING", size: head.size }
665 });
666 await this.enqueueProcessFile(file.id, file.workspaceId);
667
668 await this.audit.audit(req, undefined, {
669 action: "form.public_upload_completed",
670 resourceType: "file",
671 resourceId: file.id,
672 workspaceId: file.workspaceId,
673 actorId: "anonymous",
674 metadata: { formId: form.id, name: file.name, size: head.size }
675 });
676
677 return { id: file.id, status: "SCANNING", verifiedBy: "worker" };
678 }
679
680
681 private async loadPublicForm(slug: string) {
682 const form = await this.prisma.form.findUnique({ where: { slug } });
683 if (!form || form.status === "DRAFT") throw ApiError.notFound("Form not found.");
684 return form;
685 }
686
687
688 private async enqueueProcessFile(fileId: string, workspaceId: string): Promise<void> {
689 try {
690 const { Queue } = await import("bullmq");
691 const q = new Queue("files", {
692 connection: this.redis.getRedis().options as never,
693 defaultJobOptions: {
694 attempts: 3,
695 backoff: { type: "exponential", delay: 2_000 },
696 removeOnComplete: 500,
697 removeOnFail: 200
698 }
699 });
700
701 await q.add("process-file", { fileId, version: 1, workspaceId }, { ...jobTraceMetadata() });
702 await q.close();
703 } catch (err) {
704 logger.warn(`files queue enqueue failed for ${fileId}: ${(err as Error).message}`);
705 }
706 }
707}
708
709
710function redactIp24(ip: string): string {
711 if (!ip) return "unknown";
712 const v4 = ip.replace(/^::ffff:/, "");
713 const parts = v4.split(".");
714 if (parts.length === 4) return `${parts[0]}.${parts[1]}.${parts[2]}.***`;
715 return "***";
716}
717
718
720function fallbackTitle(formName: string, answers: Record<string, unknown>): string {
721 for (const v of Object.values(answers)) {
722 if (typeof v === "string" && v.trim() !== "") {
723 const head = v.trim().slice(0, 8);
724 return `${formName} — ${head}`.slice(0, 300);
725 }
726 }
727 return formName.slice(0, 300);
728}
729
730
731function collectFileIds(answers: Record<string, unknown>): string[] {
732 const out: string[] = [];
733 for (const v of Object.values(answers)) {
734 if (Array.isArray(v)) {
735 for (const item of v) if (typeof item === "string") out.push(item);
736 }
737 }
738 return [...new Set(out)];
739}
740
741
748function coerceForProperty(value: unknown, propType: string): unknown {
749 if (value === undefined || value === null) return null;
750 switch (propType) {
751 case "TITLE":
752 case "TEXT":
753 case "RICH_TEXT":
754 return { text: String(value), marks: [] };
755 case "NUMBER":
756 return { value: Number(value) };
757 case "SELECT":
758 case "STATUS":
759 return { optionId: String(value) };
760 case "MULTI_SELECT":
761 return { optionIds: Array.isArray(value) ? value.map(String) : [String(value)] };
762 case "DATE":
763 case "DATE_RANGE":
764 return { start: String(value), end: null };
765 case "EMAIL":
766 case "PHONE":
767 return { value: String(value) };
768 case "URL":
769 return { value: String(value), title: null };
770 case "CHECKBOX":
771 return { value: Boolean(value) };
772 case "FILES":
773 return { fileIds: Array.isArray(value) ? value : [value] };
774 default:
775
776
777
778 return value;
779 }
780}
781