diff --git a/bruno/form-fields/createFields.bru b/bruno/form-fields/createFields.bru index 0364c0b..08c2209 100644 --- a/bruno/form-fields/createFields.bru +++ b/bruno/form-fields/createFields.bru @@ -35,30 +35,30 @@ settings { docs { # Create Field - Adds a new field to a form. This endpoint handles the **linked-list ordering** logic automatically. You can insert a field at the very beginning (Head) or after a specific existing field. + Adds a new field to a form. This endpoint handles the linked-list ordering logic automatically. You can insert a field at the very beginning (Head) or after a specific existing field. - * **URL:** `/forms/:formId/fields` - * **Method:** `POST` - * **Auth Required:** Yes + - **URL:** `/forms/:formId/fields` + - **Method:** `POST` + - **Auth Required:** Yes - ### Path Parameters + ## Path Parameters | Parameter | Type | Description | - | :--- | :--- | :--- | + |---|---|---| | `formId` | `string` (UUID) | The ID of the form to add the field to. | - ### Request Body + ## Request Body | Field | Type | Required | Description | - | :--- | :--- | :--- | :--- | + |---|---|---|---| | `fieldName` | `string` | Yes | Internal unique name for the field (e.g., "user_email"). | | `label` | `string` | Yes | The question text shown to users. | | `fieldValueType` | `string` | Yes | Data type (e.g., "string", "number", "boolean"). | - | `fieldType` | `string` | Yes | UI Component type (e.g., "text", "select", "radio"). | - | `prevFieldId` | `string` | No | The ID of the field *after which* this new field should be inserted. Send `null` or omit to insert at the top (as the first question). | + | `fieldType` | `string` | Yes | UI component type (e.g., "text", "select", "radio"). | + | `prevFieldId` | `string \| null` | No | ID of the field after which the new field should be inserted. Omit or send `null` to insert at the beginning. | | `validation` | `object` | No | JSON object for validation rules (e.g., `{ "required": true }`). | - **Sample Input (Insert at Top/Head):** + ## Sample Input (Insert at Top) ```json { @@ -71,10 +71,11 @@ docs { "required": true } } + ``` - Sample Input (Insert after an existing field): - JSON + ## Sample Input (Insert After Another Field) + ```json { "fieldName": "age", "label": "How old are you?", @@ -82,13 +83,13 @@ docs { "fieldType": "number", "prevFieldId": "uuid-of-previous-field-123" } + ``` - Responses - ✅ 200 OK: Created + ## Responses - Returns the newly created field object. - JSON + ### 200 OK — Created + ```json { "success": true, "message": "Field created successfully", @@ -102,28 +103,18 @@ docs { "required": true }, "formId": "form_xyz_789", - "prevFieldId": null, - "createdAt": "2023-10-27T10:00:00.000Z" + "prevFieldId": null } } + ``` - ❌ 400 Bad Request: Invalid Position - - Occurs if prevFieldId is provided but that field does not exist in this form (meaning you are trying to insert after a field that doesn't exist). - JSON - - { - "success": false, - "message": "Previous field not found in the specified form" - } - - ❌ 404 Not Found: Form Missing - - Occurs if the form does not exist or the user does not have permission to edit it. - JSON + ### 404 Not Found — Form Missing + ```json { "success": false, "message": "Form not found" } + ``` + } diff --git a/bruno/forms/publishForm.bru b/bruno/forms/publishForm.bru index f17bb25..2a1a3ab 100644 --- a/bruno/forms/publishForm.bru +++ b/bruno/forms/publishForm.bru @@ -24,7 +24,7 @@ docs { Changes the status of a form to **Published**. Once published, the form becomes accessible to respondents for submission. - * **URL:** `/forms/:formId/publish` + * **URL:** `/forms/publish/:formId` * **Method:** `PATCH` * **Auth Required:** Yes diff --git a/bruno/forms/unPublishForm.bru b/bruno/forms/unPublishForm.bru index 62016f0..6f99f05 100644 --- a/bruno/forms/unPublishForm.bru +++ b/bruno/forms/unPublishForm.bru @@ -5,7 +5,7 @@ meta { } post { - url: http://localhost:8000/forms/publish/:formId + url: http://localhost:8000/forms/unpublish/:formId body: none auth: inherit } @@ -24,7 +24,7 @@ docs { Reverts the status of a form to **Unpublished**. This hides the form from the public, preventing any new submissions until it is published again. - * **URL:** `/forms/:formId/unpublish` + * **URL:** `/forms/unpublish/:formId` * **Method:** `PATCH` * **Auth Required:** Yes diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c54fbf..68cb046 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,9 +1,3 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - -// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? -// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init - generator client { provider = "prisma-client-js" } @@ -13,18 +7,17 @@ datasource db { } model User { - id String @id @default(uuid()) - email String @unique + id String @id @default(uuid()) + email String @unique name String? - emailVerified Boolean @default(false) + emailVerified Boolean @default(false) image String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - sessions Session[] - accounts Account[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt forms Form[] + accounts Account[] formResponses FormResponse[] + sessions Session[] @@map("user") } @@ -38,8 +31,7 @@ model Session { userAgent String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("session") } @@ -51,25 +43,22 @@ model Account { providerId String accessToken String? refreshToken String? - idToken String? - scope String? - accessTokenExpiresAt DateTime? expiresAt DateTime? password String? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + accessTokenExpiresAt DateTime? + idToken String? + scope String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("account") } -// You will need this table for "Forgot Password" or "Verify Email" flows model Verification { id String @id @default(uuid()) identifier String - value String // The token itself + value String expiresAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -78,41 +67,31 @@ model Verification { } model Form { - id String @id @default(uuid()) - title String - description String? - isPublished Boolean @default(false) - formUrl String? - - ownerId String - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - + id String @id @default(uuid()) + title String + description String? + isPublished Boolean @default(false) + formUrl String? + ownerId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) formFields FormFields[] formResponses FormResponse[] } model FormFields { - id String @id @default(uuid()) + id String @id @default(uuid()) fieldName String label String? - fieldValueType String // e.g., "string", "number", "boolean" - fieldType String // e.g., "MCQ", "Radio", "Input" - validation Json? // e.g., { "required": true, "min": 5 } - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // 1. Link to the parent Form - formId String - form Form @relation(fields: [formId], references: [id], onDelete: Cascade) - - // 2. Doubly Linked List Logic (Self-Relations) - - // The "Previous" Field (Parent) - // This field stores the ID of the field passing control to this one - prevFieldId String? + fieldValueType String + fieldType String + validation Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + formId String + prevFieldId String? + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) @@index([formId]) @@index([formId, prevFieldId]) @@ -120,18 +99,15 @@ model FormFields { } model FormResponse { - id String @id @default(uuid()) - - formId String - form Form @relation(fields: [formId], references: [id], onDelete: Cascade) - + id String @id @default(uuid()) + formId String respondentId String? - respondent User? @relation(fields: [respondentId], references: [id], onDelete: SetNull) - - answers Json - isSubmitted Boolean @default(false) - submittedAt DateTime? - updatedAt DateTime @updatedAt + answers Json + submittedAt DateTime? + updatedAt DateTime @updatedAt + isSubmitted Boolean @default(false) + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + respondent User? @relation(fields: [respondentId], references: [id]) @@unique([formId, respondentId]) @@index([formId]) diff --git a/src/api/form-response/controller.ts b/src/api/form-response/controller.ts index aad9bba..368e696 100644 --- a/src/api/form-response/controller.ts +++ b/src/api/form-response/controller.ts @@ -4,6 +4,7 @@ import type { FormResponseContext, FormResponseForFormOwnerContext, GetSubmittedResponseContext, + ResumeResponseContext, } from "../../types/form-response"; export async function submitResponse({ @@ -36,45 +37,16 @@ export async function submitResponse({ }; } - const existing = await prisma.formResponse.findUnique({ - where: { - formId_respondentId: { - formId: params.formId, - respondentId: user.id, - }, - }, - }); - - if (existing?.isSubmitted) { - set.status = 400; - return { - success: false, - message: "You have already submitted this form", - }; - } - - const response = await prisma.formResponse.upsert({ - where: { - formId_respondentId: { - formId: params.formId, - respondentId: user.id, - }, - }, - update: { - answers: body.answers, - isSubmitted: true, - submittedAt: new Date(), - }, - create: { + const response = await prisma.formResponse.create({ + data: { formId: params.formId, respondentId: user.id, answers: body.answers, - isSubmitted: true, - submittedAt: new Date(), + isSubmitted: body.isSubmitted ?? false, }, }); logger.info( - `User ${user.id} submitted response ${response.id} for form ${params.formId}`, + `User ${user.id} ${body.isSubmitted ? "submitted" : "saved draft"} response ${response.id} for form ${params.formId}`, ); return { success: true, @@ -83,66 +55,38 @@ export async function submitResponse({ }; } -export async function saveDraftResponse({ +export async function resumeResponse({ params, body, user, - set, -}: FormResponseContext) { - const form = await prisma.form.findUnique({ +}: ResumeResponseContext) { + const response = await prisma.formResponse.updateMany({ where: { - id: params.formId, + id: params.responseId, + respondentId: user.id, }, - }); - - if (!form) { - logger.warn(`Form with ID ${params.formId} not found`); - set.status = 404; - return { - success: false, - message: "Form not found", - }; - } - - const existing = await prisma.formResponse.findUnique({ - where: { - formId_respondentId: { - formId: params.formId, - respondentId: user.id, - }, + data: { + answers: body.answers, + isSubmitted: body.isSubmitted ?? false, }, }); - if (existing?.isSubmitted) { - set.status = 400; + if (response.count === 0) { + logger.warn(`No response found with ID ${params.responseId} to update`); return { success: false, - message: "Response already submitted and cannot be edited", + message: "No response found to update", }; } - const response = await prisma.formResponse.upsert({ - where: { - formId_respondentId: { - formId: params.formId, - respondentId: user.id, - }, - }, - update: { - answers: body.answers, - isSubmitted: false, - }, - create: { - formId: params.formId, - respondentId: user.id, - answers: body.answers, - }, - }); - - logger.info(`User ${user.id} saved draft response for form ${params.formId}`); + logger.info( + `Response ${params.responseId} ${body.isSubmitted ? "submitted" : "updated as draft"}`, + ); return { success: true, - message: "Draft response saved successfully", + message: body.isSubmitted + ? "Response submitted successfully" + : "Draft saved successfully", data: response, }; } @@ -173,7 +117,6 @@ export async function getResponseForFormOwner({ const responses = await prisma.formResponse.findMany({ where: { formId: params.formId, - isSubmitted: true, }, select: { id: true, @@ -243,7 +186,6 @@ export async function getSubmittedResponse({ where: { respondentId: user.id, formId: params.formId, - isSubmitted: true, }, select: { id: true, @@ -297,6 +239,7 @@ export async function getSubmittedResponse({ formId: r.formId, formTitle: r.form.title, answers: transformedAnswers, + rawAnswers: r.answers, // Include raw answers with field IDs for form loading }; }); @@ -310,57 +253,80 @@ export async function getSubmittedResponse({ }; } -export async function getDraftResponse({ - params, - user, - set, -}: GetSubmittedResponseContext) { - const draft = await prisma.formResponse.findFirst({ +// Get all responses submitted by the current user across all forms +export async function getAllUserResponses({ user }: { user: { id: string } }) { + const responses = await prisma.formResponse.findMany({ where: { respondentId: user.id, - formId: params.formId, - isSubmitted: false, }, select: { id: true, formId: true, answers: true, + isSubmitted: true, + submittedAt: true, + updatedAt: true, form: { - select: { title: true }, + select: { + id: true, + title: true, + description: true, + }, }, }, + orderBy: { + updatedAt: "desc", + }, }); - if (!draft) { - set.status = 404; + if (responses.length === 0) { + logger.info(`No responses found for user ${user.id}`); return { - success: false, - message: "No draft found", + success: true, + message: "No responses found", + data: [], }; } - const fields = await prisma.formFields.findMany({ - where: { formId: params.formId }, - select: { id: true, fieldName: true }, - }); - - const map = Object.fromEntries(fields.map((f) => [f.id, f.fieldName])); - - const transformed: Record = {}; - - for (const [fieldId, value] of Object.entries( - draft.answers as Record, - )) { - transformed[map[fieldId] ?? fieldId] = value; - } + // For each response, get the field names to transform answers + const formattedResponses = await Promise.all( + responses.map(async (r) => { + const fields = await prisma.formFields.findMany({ + where: { formId: r.formId }, + select: { id: true, fieldName: true }, + }); + + const fieldIdToNameMap = Object.fromEntries( + fields.map((f) => [f.id, f.fieldName]), + ); + + const transformedAnswers: Record = {}; + for (const [fieldId, value] of Object.entries( + r.answers as Record, + )) { + const fieldName = fieldIdToNameMap[fieldId] ?? fieldId; + transformedAnswers[fieldName] = value; + } + + return { + id: r.id, + formId: r.formId, + formTitle: r.form.title, + formDescription: r.form.description, + answers: transformedAnswers, + isSubmitted: r.isSubmitted, + submittedAt: r.submittedAt, + updatedAt: r.updatedAt, + }; + }), + ); + logger.info( + `Retrieved ${formattedResponses.length} responses for user ${user.id}`, + ); return { success: true, - data: { - id: draft.id, - formId: draft.formId, - formTitle: draft.form.title, - answers: transformed, - }, + message: "Responses retrieved successfully", + data: formattedResponses, }; } diff --git a/src/api/form-response/routes.ts b/src/api/form-response/routes.ts index eced51d..0e702a1 100644 --- a/src/api/form-response/routes.ts +++ b/src/api/form-response/routes.ts @@ -3,20 +3,22 @@ import { formResponseDTO, formResponseForFormOwnerDTO, getSubmittedResponseDTO, + resumeResponseDTO, } from "../../types/form-response"; import { requireAuth } from "../auth/requireAuth"; import { - getDraftResponse, + getAllUserResponses, getResponseForFormOwner, getSubmittedResponse, - saveDraftResponse, + resumeResponse, submitResponse, } from "./controller"; export const formResponseRoutes = new Elysia({ prefix: "/responses" }) .use(requireAuth) .post("/submit/:formId", submitResponse, formResponseDTO) - .post("/draft/:formId", saveDraftResponse, formResponseDTO) + .post("/draft/:formId", submitResponse, formResponseDTO) + .put("/resume/:responseId", resumeResponse, resumeResponseDTO) + .get("/my", getAllUserResponses) .get("/:formId", getResponseForFormOwner, formResponseForFormOwnerDTO) - .get("/user/:formId", getSubmittedResponse, getSubmittedResponseDTO) - .get("/draft/:formId", getDraftResponse, getSubmittedResponseDTO); + .get("/user/:formId", getSubmittedResponse, getSubmittedResponseDTO); diff --git a/src/api/forms/controller.ts b/src/api/forms/controller.ts index 1e8334b..62cee04 100644 --- a/src/api/forms/controller.ts +++ b/src/api/forms/controller.ts @@ -4,6 +4,7 @@ import type { Context, CreateFormContext, GetFormByIdContext, + GetPublicFormByIdContext, UpdateFormContext, } from "../../types/forms"; @@ -14,6 +15,13 @@ export async function getAllForms({ user }: Context) { title: true, isPublished: true, createdAt: true, + _count: { + select: { + formResponses: { + where: { isSubmitted: true }, + }, + }, + }, }, where: { ownerId: user.id }, }); @@ -27,6 +35,15 @@ export async function getAllForms({ user }: Context) { }; } + // Transform to include responseCount + const formsWithCount = forms.map((form) => ({ + id: form.id, + title: form.title, + isPublished: form.isPublished, + createdAt: form.createdAt, + responseCount: form._count.formResponses, + })); + logger.info("Fetched all forms for user", { userId: user.id, formCount: forms.length, @@ -34,7 +51,7 @@ export async function getAllForms({ user }: Context) { return { success: true, message: "All forms fetched successfully", - data: forms, + data: formsWithCount, }; } @@ -63,6 +80,13 @@ export async function getFormById({ user, params, set }: GetFormByIdContext) { id: params.formId, ownerId: user.id, }, + select: { + id: true, + title: true, + description: true, + isPublished: true, + createdAt: true, + }, }); if (!form) { @@ -73,11 +97,38 @@ export async function getFormById({ user, params, set }: GetFormByIdContext) { }; } + const fields = await prisma.formFields.findMany({ + where: { formId: params.formId }, + }); + + if (fields.length === 0) { + logger.info(`No fields found for formId: ${params.formId}`); + return { + success: true, + message: "Form fetched successfully (no fields)", + data: { ...form, fields: [] }, + }; + } + + const ordered: typeof fields = []; + + let current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === null, + ); + + while (current) { + ordered.push(current); + + current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === current!.id, + ); + } + logger.info("Fetched form for user", { userId: user.id, formId: form.id }); return { success: true, message: "Form fetched successfully", - data: form, + data: { ...form, fields: ordered }, }; } @@ -224,3 +275,64 @@ export async function unPublishForm({ user, params, set }: GetFormByIdContext) { data: form, }; } + +// Public endpoint - any authenticated user can access published forms +export async function getPublicFormById({ + params, + set, +}: GetPublicFormByIdContext) { + const form = await prisma.form.findFirst({ + where: { + id: params.formId, + isPublished: true, // Only allow access to published forms + }, + select: { + id: true, + title: true, + description: true, + isPublished: true, + createdAt: true, + }, + }); + + if (!form) { + set.status = 404; + return { + success: false, + message: "Form not found or not published", + }; + } + + const fields = await prisma.formFields.findMany({ + where: { formId: params.formId }, + }); + + if (fields.length === 0) { + logger.info(`No fields found for public formId: ${params.formId}`); + return { + success: true, + message: "Form fetched successfully (no fields)", + data: { ...form, fields: [] }, + }; + } + + // Order fields by linked list structure + const ordered: typeof fields = []; + let current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === null, + ); + + while (current) { + ordered.push(current); + current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === current!.id, + ); + } + + logger.info("Fetched public form", { formId: form.id }); + return { + success: true, + message: "Form fetched successfully", + data: { ...form, fields: ordered }, + }; +} diff --git a/src/api/forms/routes.ts b/src/api/forms/routes.ts index e6c8de1..bac39f6 100644 --- a/src/api/forms/routes.ts +++ b/src/api/forms/routes.ts @@ -10,11 +10,20 @@ import { deleteForm, getAllForms, getFormById, + getPublicFormById, publishForm, unPublishForm, updateForm, } from "./controller"; +// Public routes (no auth required for respondents) +export const publicFormRoutes = new Elysia({ prefix: "/forms" }).get( + "/public/:formId", + getPublicFormById, + getFormByIdDTO, +); + +// Protected routes (require auth) export const formRoutes = new Elysia({ prefix: "/forms" }) .use(requireAuth) .get("/", getAllForms) diff --git a/src/index.ts b/src/index.ts index d0d3e53..06697e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Elysia } from "elysia"; import { authRoutes } from "./api/auth/routes"; import { formFieldRoutes } from "./api/form-fields/routes"; import { formResponseRoutes } from "./api/form-response/routes"; -import { formRoutes } from "./api/forms/routes"; +import { formRoutes, publicFormRoutes } from "./api/forms/routes"; import { logger } from "./logger/index"; const app = new Elysia() @@ -45,6 +45,7 @@ const app = new Elysia() }) .get("/", () => "🦊 Elysia server started") .use(authRoutes) + .use(publicFormRoutes) // Public routes first (no auth) .use(formRoutes) .use(formFieldRoutes) .use(formResponseRoutes); diff --git a/src/test/form-response.test.ts b/src/test/form-response.test.ts index 2b4ea3e..683f2ab 100644 --- a/src/test/form-response.test.ts +++ b/src/test/form-response.test.ts @@ -3,10 +3,9 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; // ---------------- MOCK PRISMA ---------------- const formFindUniqueMock = mock(); -const formResponseFindUniqueMock = mock(); -const formResponseUpsertMock = mock(); +const formResponseCreateMock = mock(); +const formResponseUpdateManyMock = mock(); const formResponseFindManyMock = mock(); -const formResponseFindFirstMock = mock(); const formFieldsFindManyMock = mock(); mock.module("../db/prisma", () => ({ @@ -15,10 +14,9 @@ mock.module("../db/prisma", () => ({ findUnique: formFindUniqueMock, }, formResponse: { - findUnique: formResponseFindUniqueMock, - upsert: formResponseUpsertMock, + create: formResponseCreateMock, + updateMany: formResponseUpdateManyMock, findMany: formResponseFindManyMock, - findFirst: formResponseFindFirstMock, }, formFields: { findMany: formFieldsFindManyMock, @@ -42,19 +40,18 @@ mock.module("../logger", () => ({ // IMPORT AFTER MOCKS const { submitResponse, - saveDraftResponse, + resumeResponse, getResponseForFormOwner, getSubmittedResponse, - getDraftResponse, + getAllUserResponses, } = await import("../api/form-response/controller"); describe("Form Response Controller Tests", () => { beforeEach(() => { formFindUniqueMock.mockReset(); - formResponseFindUniqueMock.mockReset(); - formResponseUpsertMock.mockReset(); + formResponseCreateMock.mockReset(); + formResponseUpdateManyMock.mockReset(); formResponseFindManyMock.mockReset(); - formResponseFindFirstMock.mockReset(); formFieldsFindManyMock.mockReset(); mockInfo.mockReset(); mockWarn.mockReset(); @@ -66,21 +63,19 @@ describe("Form Response Controller Tests", () => { // submitResponse // ===================================================== - it("submitResponse → success", async () => { + it("submitResponse → success (submitted)", async () => { formFindUniqueMock.mockResolvedValue({ id: "f1", isPublished: true, }); - formResponseFindUniqueMock.mockResolvedValue(null); - - formResponseUpsertMock.mockResolvedValue({ id: "r1" }); + formResponseCreateMock.mockResolvedValue({ id: "r1" }); const set: any = {}; const res = await submitResponse({ params: { formId: "f1" }, - body: { answers: {} }, + body: { answers: {}, isSubmitted: true }, user, set, } as any); @@ -123,68 +118,44 @@ describe("Form Response Controller Tests", () => { expect(set.status).toBe(403); }); - it("submitResponse → already submitted", async () => { - formFindUniqueMock.mockResolvedValue({ - id: "f1", - isPublished: true, - }); - - formResponseFindUniqueMock.mockResolvedValue({ - isSubmitted: true, - }); + // ===================================================== + // resumeResponse + // ===================================================== - const set: any = {}; + it("resumeResponse → success (submit)", async () => { + formResponseUpdateManyMock.mockResolvedValue({ count: 1 }); - const res = await submitResponse({ - params: { formId: "f1" }, - body: { answers: {} }, + const res = await resumeResponse({ + params: { responseId: "r1" }, + body: { answers: {}, isSubmitted: true }, user, - set, } as any); - expect(res.success).toBe(false); - expect(set.status).toBe(400); + expect(res.success).toBe(true); }); - // ===================================================== - // saveDraftResponse - // ===================================================== - - it("saveDraftResponse → success", async () => { - formFindUniqueMock.mockResolvedValue({ id: "f1" }); - formResponseFindUniqueMock.mockResolvedValue(null); - formResponseUpsertMock.mockResolvedValue({ id: "r1" }); - - const set: any = {}; + it("resumeResponse → success (draft)", async () => { + formResponseUpdateManyMock.mockResolvedValue({ count: 1 }); - const res = await saveDraftResponse({ - params: { formId: "f1" }, - body: { answers: {} }, + const res = await resumeResponse({ + params: { responseId: "r1" }, + body: { answers: {}, isSubmitted: false }, user, - set, } as any); expect(res.success).toBe(true); }); - it("saveDraftResponse → already submitted", async () => { - formFindUniqueMock.mockResolvedValue({ id: "f1" }); + it("resumeResponse → not found", async () => { + formResponseUpdateManyMock.mockResolvedValue({ count: 0 }); - formResponseFindUniqueMock.mockResolvedValue({ - isSubmitted: true, - }); - - const set: any = {}; - - const res = await saveDraftResponse({ - params: { formId: "f1" }, + const res = await resumeResponse({ + params: { responseId: "r1" }, body: { answers: {} }, user, - set, } as any); expect(res.success).toBe(false); - expect(set.status).toBe(400); }); // ===================================================== @@ -222,6 +193,40 @@ describe("Form Response Controller Tests", () => { expect(res.data!.length).toBe(1); }); + it("getResponseForFormOwner → form not found", async () => { + formFindUniqueMock.mockResolvedValue(null); + + const set: any = {}; + + const res = await getResponseForFormOwner({ + params: { formId: "f1" }, + user, + set, + } as any); + + expect(res.success).toBe(false); + expect(set.status).toBe(404); + }); + + it("getResponseForFormOwner → no responses", async () => { + formFindUniqueMock.mockResolvedValue({ + id: "f1", + ownerId: user.id, + }); + + formResponseFindManyMock.mockResolvedValue([]); + + const set: any = {}; + + const res = await getResponseForFormOwner({ + params: { formId: "f1" }, + user, + set, + } as any); + + expect(res.success).toBe(false); + }); + // ===================================================== // getSubmittedResponse // ===================================================== @@ -252,46 +257,58 @@ describe("Form Response Controller Tests", () => { expect(res.data!.length).toBe(1); }); + it("getSubmittedResponse → none found", async () => { + formResponseFindManyMock.mockResolvedValue([]); + + const set: any = {}; + + const res = await getSubmittedResponse({ + params: { formId: "f1" }, + user, + set, + } as any); + + expect(res.success).toBe(false); + expect(set.status).toBe(404); + }); + // ===================================================== - // getDraftResponse + // getAllUserResponses // ===================================================== - it("getDraftResponse → success", async () => { - formResponseFindFirstMock.mockResolvedValue({ - id: "r1", - formId: "f1", - answers: { field1: "A" }, - form: { title: "Form A" }, - }); + it("getAllUserResponses → success", async () => { + formResponseFindManyMock.mockResolvedValue([ + { + id: "r1", + formId: "f1", + answers: { field1: "A" }, + isSubmitted: true, + submittedAt: new Date(), + updatedAt: new Date(), + form: { + id: "f1", + title: "Form A", + description: "Desc", + }, + }, + ]); formFieldsFindManyMock.mockResolvedValue([ { id: "field1", fieldName: "Name" }, ]); - const set: any = {}; - - const res = await getDraftResponse({ - params: { formId: "f1" }, - user, - set, - } as any); + const res = await getAllUserResponses({ user }); expect(res.success).toBe(true); - expect(res.data!.id).toBe("r1"); + expect(res.data.length).toBe(1); }); - it("getDraftResponse → not found", async () => { - formResponseFindFirstMock.mockResolvedValue(null); + it("getAllUserResponses → empty", async () => { + formResponseFindManyMock.mockResolvedValue([]); - const set: any = {}; - - const res = await getDraftResponse({ - params: { formId: "f1" }, - user, - set, - } as any); + const res = await getAllUserResponses({ user }); - expect(res.success).toBe(false); - expect(set.status).toBe(404); + expect(res.success).toBe(true); + expect(res.data).toEqual([]); }); }); diff --git a/src/test/forms.test.ts b/src/test/forms.test.ts index 606d4b0..5b90f87 100644 --- a/src/test/forms.test.ts +++ b/src/test/forms.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; // ---------- MOCK PRISMA ---------- -const findManyMock = mock(); +const formFindManyMock = mock(); +const formFieldsFindManyMock = mock(); const createMock = mock(); const findFirstMock = mock(); const updateMock = mock(); @@ -10,12 +11,15 @@ const deleteManyMock = mock(); mock.module("../db/prisma", () => ({ prisma: { form: { - findMany: findManyMock, + findMany: formFindManyMock, create: createMock, findFirst: findFirstMock, update: updateMock, deleteMany: deleteManyMock, }, + formFields: { + findMany: formFieldsFindManyMock, + }, }, })); @@ -32,19 +36,13 @@ mock.module("../logger", () => ({ })); // IMPORT AFTER MOCKS -const { - getAllForms, - createForm, - getFormById, - updateForm, - deleteForm, - publishForm, - unPublishForm, -} = await import("../api/forms/controller"); +const { getAllForms, createForm, getFormById, updateForm, deleteForm } = + await import("../api/forms/controller"); describe("Forms Controller Tests", () => { beforeEach(() => { - findManyMock.mockReset(); + formFindManyMock.mockReset(); + formFieldsFindManyMock.mockReset(); createMock.mockReset(); findFirstMock.mockReset(); updateMock.mockReset(); @@ -55,33 +53,41 @@ describe("Forms Controller Tests", () => { const user = { id: "user1" }; - // ============================== - // getAllForms - // ============================== + // ===== getAllForms ===== it("getAllForms → success", async () => { - findManyMock.mockResolvedValue([ - { id: "1", title: "A", isPublished: true, createdAt: new Date() }, + formFindManyMock.mockResolvedValue([ + { + id: "1", + title: "A", + isPublished: true, + createdAt: new Date(), + _count: { formResponses: 3 }, + }, ]); - const res = await getAllForms({ user } as any); + const res: any = await getAllForms({ user } as any); expect(res.success).toBe(true); - expect(res.data!.length).toBe(1); + expect(res.data.length).toBe(1); + expect(res.data[0].responseCount).toBe(3); }); it("getAllForms → empty", async () => { - findManyMock.mockResolvedValue([]); + formFindManyMock.mockResolvedValue([]); - const res = await getAllForms({ user } as any); + const res: any = await getAllForms({ user } as any); expect(res.message).toBe("No forms found"); expect(res.data).toEqual([]); }); - // ============================== - // createForm - // ============================== + it("getAllForms → DB error", async () => { + formFindManyMock.mockRejectedValue(new Error("DB fail")); + expect(getAllForms({ user } as any)).rejects.toThrow(); + }); + + // ===== createForm ===== it("createForm → success", async () => { createMock.mockResolvedValue({ id: "1", title: "New" }); @@ -94,133 +100,143 @@ describe("Forms Controller Tests", () => { expect(res.success).toBe(true); }); - // ============================== - // getFormById - // ============================== + it("createForm → called", async () => { + createMock.mockResolvedValue({ id: "1" }); - it("getFormById → found", async () => { - findFirstMock.mockResolvedValue({ id: "1" }); - - const set: any = {}; - const res = await getFormById({ + await createForm({ user, - params: { formId: "1" }, - set, + body: { title: "T", description: "D" }, } as any); - expect(res.success).toBe(true); + expect(createMock).toHaveBeenCalled(); }); - it("getFormById → not found", async () => { - findFirstMock.mockResolvedValue(null); - - const set: any = {}; - const res = await getFormById({ - user, - params: { formId: "2" }, - set, - } as any); + it("createForm → DB error", async () => { + createMock.mockRejectedValue(new Error("DB crash")); - expect(res.success).toBe(false); - expect(set.status).toBe(404); + expect( + createForm({ + user, + body: { title: "X", description: "Y" }, + } as any), + ).rejects.toThrow(); }); - // ============================== - // updateForm - // ============================== + // ===== getFormById ===== - it("updateForm → success", async () => { - findFirstMock.mockResolvedValue({ id: "1" }); - updateMock.mockResolvedValue({ id: "1" }); + it("getFormById → found with ordered fields", async () => { + findFirstMock.mockResolvedValue({ + id: "1", + title: "Test Form", + description: "Desc", + isPublished: false, + createdAt: new Date(), + }); + + formFieldsFindManyMock.mockResolvedValue([ + { id: "f1", formId: "1", prevFieldId: null }, + { id: "f2", formId: "1", prevFieldId: "f1" }, + ]); const set: any = {}; - const res = await updateForm({ + const res: any = await getFormById({ user, params: { formId: "1" }, - body: { title: "Updated", description: "D" }, set, } as any); expect(res.success).toBe(true); + expect(res.data.id).toBe("1"); + expect(res.data.fields.length).toBe(2); + expect(res.data.fields[0].id).toBe("f1"); + expect(res.data.fields[1].id).toBe("f2"); }); - it("updateForm → not found", async () => { - findFirstMock.mockResolvedValue(null); + it("getFormById → no fields", async () => { + findFirstMock.mockResolvedValue({ + id: "1", + title: "Test Form", + description: "Desc", + isPublished: false, + createdAt: new Date(), + }); + + formFieldsFindManyMock.mockResolvedValue([]); const set: any = {}; - const res = await updateForm({ + const res: any = await getFormById({ user, params: { formId: "1" }, - body: { title: "T", description: "D" }, set, } as any); - expect(res.success).toBe(false); - expect(set.status).toBe(404); + expect(res.success).toBe(true); + expect(res.message).toBe("Form fetched successfully (no fields)"); + expect(res.data.fields).toEqual([]); }); - // ============================== - // deleteForm - // ============================== - - it("deleteForm → success", async () => { - deleteManyMock.mockResolvedValue({ count: 1 }); + it("getFormById → not found", async () => { + findFirstMock.mockResolvedValue(null); const set: any = {}; - const res = await deleteForm({ + const res = await getFormById({ user, - params: { formId: "1" }, + params: { formId: "2" }, set, } as any); - expect(res.success).toBe(true); + expect(res.success).toBe(false); + expect(set.status).toBe(404); }); - it("deleteForm → not found", async () => { - deleteManyMock.mockResolvedValue({ count: 0 }); + it("getFormById → DB error", async () => { + findFirstMock.mockRejectedValue(new Error("DB error")); const set: any = {}; - const res = await deleteForm({ - user, - params: { formId: "1" }, - set, - } as any); - - expect(res.success).toBe(false); - expect(set.status).toBe(404); + await expect( + getFormById({ + user, + params: { formId: "1" }, + set, + } as any), + ).rejects.toThrow(); }); - // ============================== - // publishForm - // ============================== + // ===== updateForm ===== - it("publishForm → success", async () => { + it("updateForm → success", async () => { findFirstMock.mockResolvedValue({ id: "1" }); - updateMock.mockResolvedValue({ id: "1", isPublished: true }); + + updateMock.mockResolvedValue({ + id: "1", + title: "Updated", + }); const set: any = {}; - const res = await publishForm({ + const res = await updateForm({ user, params: { formId: "1" }, + body: { title: "Updated", description: "D" }, set, } as any); expect(res.success).toBe(true); }); - it("publishForm → not found", async () => { + it("updateForm → not found", async () => { findFirstMock.mockResolvedValue(null); const set: any = {}; - const res = await publishForm({ + const res = await updateForm({ user, params: { formId: "1" }, + body: { title: "T", description: "D" }, set, } as any); @@ -228,17 +244,29 @@ describe("Forms Controller Tests", () => { expect(set.status).toBe(404); }); - // ============================== - // unPublishForm - // ============================== + it("updateForm → DB error", async () => { + findFirstMock.mockRejectedValue(new Error("DB fail")); - it("unPublishForm → success", async () => { - findFirstMock.mockResolvedValue({ id: "1" }); - updateMock.mockResolvedValue({ id: "1", isPublished: false }); + const set: any = {}; + + expect( + updateForm({ + user, + params: { formId: "1" }, + body: { title: "T", description: "D" }, + set, + } as any), + ).rejects.toThrow(); + }); + + // ===== deleteForm ===== + + it("deleteForm → success", async () => { + deleteManyMock.mockResolvedValue({ count: 1 }); const set: any = {}; - const res = await unPublishForm({ + const res = await deleteForm({ user, params: { formId: "1" }, set, @@ -247,12 +275,12 @@ describe("Forms Controller Tests", () => { expect(res.success).toBe(true); }); - it("unPublishForm → not found", async () => { - findFirstMock.mockResolvedValue(null); + it("deleteForm → not found", async () => { + deleteManyMock.mockResolvedValue({ count: 0 }); const set: any = {}; - const res = await unPublishForm({ + const res = await deleteForm({ user, params: { formId: "1" }, set, @@ -261,4 +289,18 @@ describe("Forms Controller Tests", () => { expect(res.success).toBe(false); expect(set.status).toBe(404); }); + + it("deleteForm → DB error", async () => { + deleteManyMock.mockRejectedValue(new Error("DB crash")); + + const set: any = {}; + + expect( + deleteForm({ + user, + params: { formId: "1" }, + set, + } as any), + ).rejects.toThrow(); + }); }); diff --git a/src/types/form-response.ts b/src/types/form-response.ts index db817c2..30c0a7d 100644 --- a/src/types/form-response.ts +++ b/src/types/form-response.ts @@ -23,6 +23,7 @@ export const formResponseDTO = { t.Null(), ]), ), + isSubmitted: t.Optional(t.Boolean()), // true = final submission, false/undefined = draft }), }; @@ -49,6 +50,7 @@ export const resumeResponseDTO = { t.Null(), ]), ), + isSubmitted: t.Optional(t.Boolean()), // true = final submission, false/undefined = draft }), }; diff --git a/src/types/forms.ts b/src/types/forms.ts index cdf9bb4..d2d3b77 100644 --- a/src/types/forms.ts +++ b/src/types/forms.ts @@ -5,6 +5,11 @@ export interface Context { set: { status?: number | string }; } +// Public context (no user required) +export interface PublicContext { + set: { status?: number | string }; +} + export const createFormDTO = { body: t.Object({ title: t.String(), @@ -28,6 +33,11 @@ export interface GetFormByIdContext extends Context { params: Static; } +// Public context for fetching published forms (no auth required) +export interface GetPublicFormByIdContext extends PublicContext { + params: Static; +} + export const updateFormDTO = { params: t.Object({ formId: t.String({