1
13import { Inject, Injectable } from "@nestjs/common";
14import { Prisma } from "@prisma/client";
15import { PrismaService } from "../../common/prisma.service.js";
16import { ApiError, type ErrorDetail } from "../../common/errors.js";
17import { assertCan, type AuthorizePrisma } from "../../lib/authorize.js";
18import { between, isValid } from "../../lib/positions.js";
19import {
20 FORM_ERROR_CODES,
21 QUESTION_PROPERTY_COMPATIBILITY,
22 compatiblePropertyTypes,
23 isCompatibleMapping,
24 validateQuestionValidation,
25 type FormQuestionType,
26 type DbPropertyType
27} from "@factory/shared";
28import type { CreateQuestionInput, UpdateQuestionInput } from "./question.dto.js";
29
30export interface QuestionRow {
31 id: string;
32 formId: string;
33 label: string;
34 description: string | null;
35 type: string;
36 required: boolean;
37 validation: unknown;
38 propertyId: string | null;
39 placeholder: string | null;
40 position: string;
41}
42
43@Injectable()
44export class QuestionsService {
45 constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
46
47
48 async list(formId: string): Promise<QuestionRow[]> {
49 return (await this.prisma.formQuestion.findMany({
50 where: { formId },
51 orderBy: { position: "asc" }
52 })) as unknown as QuestionRow[];
53 }
54
55 private async loadFormChecked(
56 formId: string,
57 userId: string,
58 need: "VIEW" | "EDIT"
59 ): Promise<{ id: string; workspaceId: string; databaseId: string }> {
60 const form = await this.prisma.form.findUnique({ where: { id: formId } });
61 if (!form) throw ApiError.notFound("Form not found.");
62 await assertCan(this.prisma, { workspaceId: form.workspaceId, userId, resource: { databaseId: form.databaseId } }, need);
63 return { id: form.id, workspaceId: form.workspaceId, databaseId: form.databaseId };
64 }
65
66
67 private async properties(databaseId: string) {
68 return this.prisma.databaseProperty.findMany({
69 where: { databaseId, deletedAt: null },
70 select: { id: true, name: true, type: true, isTitle: true }
71 });
72 }
73
74
78 private async validateQuestion(
79 form: { id: string; databaseId: string },
80 type: FormQuestionType,
81 validation: unknown,
82 propertyId: string | null | undefined
83 ): Promise<void> {
84 const errors: ErrorDetail[] = [];
85
86
87 const check = await validateQuestionValidation(type, validation ?? undefined);
88 if (!check.valid) {
89 errors.push({ field: "validation", issue: check.issue ?? "invalid", message: check.message });
90 }
91
92
93 if (propertyId) {
94 const props = await this.properties(form.databaseId);
95 const prop = props.find((p) => p.id === propertyId);
96 if (!prop) {
97 errors.push({
98 field: "propertyId",
99 issue: FORM_ERROR_CODES.UNKNOWN_PROPERTY,
100 message: "property does not exist on the form's database"
101 });
102 } else if (!isCompatibleMapping(type, prop.type as DbPropertyType)) {
103 errors.push({
104 field: "propertyId",
105 issue: FORM_ERROR_CODES.PROPERTY_TYPE_MISMATCH,
106 message: `${type} cannot map to a ${prop.type} property`,
107 allowedTypes: this.compatibleList(type)
108 });
109 }
110 }
111
112 if (errors.length > 0) {
113 throw new ApiError("UNPROCESSABLE_ENTITY", 422, "Question is invalid.", errors);
114 }
115 }
116
117
118 private compatibleList(type: FormQuestionType): string[] {
119 return [...compatiblePropertyTypes(type)];
120 }
121
122 async create(formId: string, userId: string, input: CreateQuestionInput) {
123 const form = await this.loadFormChecked(formId, userId, "EDIT");
124 await this.validateQuestion(form, input.type, input.validation, input.propertyId);
125
126 const existing = await this.list(formId);
127 const last = existing.length > 0 ? existing[existing.length - 1]?.position : undefined;
128
129
130 const position = between(last);
131
132 const q = await this.prisma.formQuestion.create({
133 data: {
134 formId,
135 label: input.label,
136 description: input.description ?? null,
137 type: input.type,
138 required: input.required,
139 validation: (input.validation ?? {}) as object,
140 propertyId: input.propertyId ?? null,
141 placeholder: input.placeholder ?? null,
142 position
143 }
144 });
145 return q as unknown as QuestionRow;
146 }
147
148 async update(formId: string, qid: string, userId: string, input: UpdateQuestionInput) {
149 const form = await this.loadFormChecked(formId, userId, "EDIT");
150 const existing = await this.prisma.formQuestion.findFirst({ where: { id: qid, formId } });
151 if (!existing) throw ApiError.notFound("Question not found.");
152
153 const type = (input.type ?? existing.type) as FormQuestionType;
154 const validation = input.validation !== undefined ? input.validation : existing.validation;
155 const propertyId = input.propertyId !== undefined ? input.propertyId : existing.propertyId;
156
157 await this.validateQuestion(form, type, validation, propertyId);
158
159 const data: Prisma.FormQuestionUpdateInput = {};
160 if (input.type !== undefined) data.type = input.type as never;
161 if (input.label !== undefined) data.label = input.label;
162 if (input.description !== undefined) data.description = input.description;
163 if (input.required !== undefined) data.required = input.required;
164 if (input.validation !== undefined) {
165 data.validation = input.validation === null ? Prisma.JsonNull : (input.validation as Prisma.InputJsonValue);
166 }
167 if (input.propertyId !== undefined) data.propertyId = input.propertyId;
168 if (input.placeholder !== undefined) data.placeholder = input.placeholder;
169 if (input.position !== undefined && isValid(input.position)) data.position = input.position;
170 const updated = await this.prisma.formQuestion.update({ where: { id: qid }, data });
171 return updated as unknown as QuestionRow;
172 }
173
174 async delete(formId: string, qid: string, userId: string) {
175 const form = await this.loadFormChecked(formId, userId, "EDIT");
176 const existing = await this.prisma.formQuestion.findFirst({ where: { id: qid, formId } });
177 if (!existing) throw ApiError.notFound("Question not found.");
178 await this.prisma.formQuestion.delete({ where: { id: qid } });
179
180 return { deleted: true };
181 }
182
183
188 async reorder(
189 formId: string,
190 userId: string,
191 body: { fromId: string; toId?: string | null }
192 ): Promise<QuestionRow[]> {
193 const form = await this.loadFormChecked(formId, userId, "EDIT");
194 const questions = await this.list(formId);
195 const order = questions.map((q) => q.id);
196 const fromIdx = order.indexOf(body.fromId);
197 if (fromIdx === -1) throw ApiError.notFound("Question not found.");
198
199 const withoutFrom = order.filter((id) => id !== body.fromId);
200 let insertAt = withoutFrom.length;
201 if (body.toId) {
202 const toIdx = withoutFrom.indexOf(body.toId);
203 if (toIdx === -1) throw ApiError.notFound("Target question not found.");
204 insertAt = toIdx;
205 }
206 const newOrder = [...withoutFrom.slice(0, insertAt), body.fromId, ...withoutFrom.slice(insertAt)];
207
208
209
210
211
212 let cursor: string | undefined;
213 const finalPositions = newOrder.map((id) => {
214 const position = between(cursor);
215 cursor = position;
216 return { id, position };
217 });
218 await this.prisma.$transaction(
219 finalPositions.map((p) => this.prisma.formQuestion.update({ where: { id: p.id }, data: { position: p.position } }))
220 );
221 return this.list(formId);
222 }
223}
224