Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,3 @@ db-data/
coverage/

**/.DS_Store

package-lock.json
3 changes: 0 additions & 3 deletions migrations/0001_dry_ikaris.sql

This file was deleted.

4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@
"vitest": "^4.0.8"
},
"lint-staged": {
"*": [
"eslint --fix"
]
"*": ["eslint --fix"]
}
}
12 changes: 6 additions & 6 deletions scripts/seed-data/questions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,21 @@ import { FORM_1_ID, FORM_2_ID } from "./forms";
export const questions = [
// Form 1 questions (Hacker Application)
{
formQuestionRef: "firstName",
formQuestionId: "firstName",
formId: FORM_1_ID,
seasonCode: "S26",
questionType: "text",
tags: ["required", "profile"],
},
{
formQuestionRef: "lastName",
formQuestionId: "lastName",
formId: FORM_1_ID,
seasonCode: "S26",
questionType: "text",
tags: ["required", "profile"],
},
{
formQuestionRef: "email",
formQuestionId: "email",
formId: FORM_1_ID,
seasonCode: "S26",
questionType: "text",
Expand All @@ -29,21 +29,21 @@ export const questions = [

// Form 2 questions (Workshop Feedback)
{
formQuestionRef: "workshopName",
formQuestionId: "workshopName",
formId: FORM_2_ID,
seasonCode: "S26",
questionType: "text",
tags: ["required"],
},
{
formQuestionRef: "rating",
formQuestionId: "rating",
formId: FORM_2_ID,
seasonCode: "S26",
questionType: "number",
tags: ["required"],
},
{
formQuestionRef: "comments",
formQuestionId: "comments",
formId: FORM_2_ID,
seasonCode: "S26",
questionType: "text",
Expand Down
4 changes: 2 additions & 2 deletions src/db/schema/formQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { form } from "./form";
export const formQuestion = pgTable(
"formQuestion",
{
formQuestionRef: varchar("formQuestionRef", { length: 80 }).notNull(),
formQuestionId: varchar("formQuestionId", { length: 80 }),
formId: uuid("formId")
.notNull()
.default(sql`uuidv7()`),
Expand All @@ -28,7 +28,7 @@ export const formQuestion = pgTable(
.default(sql`ARRAY[]::text[]`),
},
(t) => [
primaryKey({ columns: [t.formQuestionRef, t.formId, t.seasonCode] }),
primaryKey({ columns: [t.formQuestionId, t.formId, t.seasonCode] }),
foreignKey({
columns: [t.seasonCode, t.formId],
foreignColumns: [form.seasonCode, form.formId], // form(seasonCode, eventId)
Expand Down
28 changes: 6 additions & 22 deletions src/resources/form/forms.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("Forms routes", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
questions: [
{ formQuestionRef: "q1", questionType: "text", tags: ["required"] },
{ formQuestionId: "q1", questionType: "text", tags: ["required"] },
],
}),
});
Expand All @@ -175,7 +175,7 @@ describe("Forms routes", () => {
closeTime: null,
tags: [],
questions: [
{ formQuestionRef: "q1", questionType: "text", tags: ["required"] },
{ formQuestionId: "q1", questionType: "text", tags: ["required"] },
],
});
});
Expand Down Expand Up @@ -211,33 +211,17 @@ describe("Forms routes", () => {
closeTime: null,
tags: ["registration", "updated"],
};

// Make absolutely sure the mock is set up correctly
const cloneFormMock = vi.fn();
cloneFormMock.mockResolvedValue(mockCloned);

// Replace the mock in the module
vi.mocked(cloneForm).mockImplementation(cloneFormMock);
(cloneForm as Mock).mockResolvedValue(mockCloned);

const res = await app.request(`/seasons/S26/forms/${FORM_ID}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
newQuestionRefs: ["new_q1"],
}),
body: JSON.stringify({}),
});

expect(res.status).toBe(201);
});
it("returns 400 for invalid request", async () => {
// Test one validation case - missing required field
const res = await app.request(`/seasons/S26/forms/${FORM_ID}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}), // Missing newQuestionRefs
});

expect(res.status).toBe(400);
expect(await res.json()).toEqual(mockCloned);
expect(cloneForm).toHaveBeenCalledWith("S26", FORM_ID);
});
});

Expand Down
14 changes: 2 additions & 12 deletions src/resources/form/forms.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { genericErrorResponse } from "@/config/openapi";
import { requireRoles, UserType } from "@/lib/auth";

const questionSchema = z.object({
formQuestionRef: z.string().max(80),
formQuestionId: z.string().max(80),
questionType: z.string(),
tags: z.array(z.string()).optional().default([]),
});
Expand All @@ -38,10 +38,6 @@ const formSchema = z.object({
tags: z.array(z.string()),
});

const cloneFormBodySchema = z.object({
newQuestionRefs: z.array(z.string().max(80)),
});

const formsRoute = new Hono();

// --- CREATE ---
Expand Down Expand Up @@ -111,17 +107,11 @@ formsRoute.post(
"param",
z.object({ seasonCode: z.string().length(3), formId: z.string().uuid() }),
),
validator("json", cloneFormBodySchema), // VALIDATOR INFERS REQUEST BODY (CONSISTENT WITH CREATE)
requireRoles(UserType.Admin),
async (c) => {
const params = c.req.valid("param");
const body = c.req.valid("json");

const cloned = await cloneForm(
params.seasonCode,
params.formId,
body.newQuestionRefs,
);
const cloned = await cloneForm(params.seasonCode, params.formId);

return c.json(
{
Expand Down
35 changes: 11 additions & 24 deletions src/resources/form/forms.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { and, eq } from "drizzle-orm";
import { ApiError } from "@/lib/errors";

export interface CreateFormQuestionInput {
formQuestionRef: string;
formQuestionId: string;
questionType: string;
tags?: string[];
}
Expand Down Expand Up @@ -34,7 +34,7 @@ export const createForm = async (input: CreateFormInput) => {
if (input.questions && input.questions.length > 0) {
await tx.insert(formQuestion).values(
input.questions.map((q) => ({
formQuestionRef: q.formQuestionRef,
formQuestionId: q.formQuestionId,
formId: createdForm.formId,
seasonCode: input.seasonCode,
questionType: q.questionType,
Expand Down Expand Up @@ -115,7 +115,7 @@ export const updateForm = async (input: UpdateFormInput) => {
if (input.questions.length > 0) {
await tx.insert(formQuestion).values(
input.questions.map((q) => ({
formQuestionRef: q.formQuestionRef,
formQuestionId: q.formQuestionId,
formId: input.formId,
seasonCode: input.seasonCode,
questionType: q.questionType,
Expand Down Expand Up @@ -158,14 +158,10 @@ export const deleteForm = async (seasonCode: string, formId: string) => {
}
};

export const cloneForm = async (
seasonCode: string,
formId: string,
newQuestionRefs: string[], // Array of new refs in SAME ORDER as source
) => {
export const cloneForm = async (seasonCode: string, formId: string) => {
try {
return await db.transaction(async (tx) => {
// Load source form
// Load existing form
const [src] = await tx
.select()
.from(form)
Expand All @@ -179,7 +175,7 @@ export const cloneForm = async (
});
}

// Load source questions
// Load existing questions
const srcQuestions = await tx
.select()
.from(formQuestion)
Expand All @@ -190,16 +186,7 @@ export const cloneForm = async (
),
);

// Validate: must provide refs for ALL questions
if (newQuestionRefs.length !== srcQuestions.length) {
throw new ApiError(400, {
code: "QUESTION_COUNT_MISMATCH",
message: "Must provide new refs for all questions",
suggestion: `Source has ${srcQuestions.length} questions, got ${newQuestionRefs.length} refs`,
});
}

// Create cloned form (identical except new ID)
// Create new form (same fields)
const [cloned] = await tx
.insert(form)
.values({
Expand All @@ -210,13 +197,13 @@ export const cloneForm = async (
})
.returning();

// Clone questions with NEW refs (in same order)
// Clone questions (new formId; new question ids to avoid collisions)
if (srcQuestions.length > 0) {
await tx.insert(formQuestion).values(
srcQuestions.map((q, index) => ({
formQuestionRef: newQuestionRefs[index], // Use provided new ref
srcQuestions.map((q) => ({
formQuestionId: crypto.randomUUID(),
formId: cloned.formId,
seasonCode: src.seasonCode,
seasonCode,
questionType: q.questionType,
tags: q.tags ?? [],
})),
Expand Down
Loading