diff --git a/backend/bruno/form-response/folder.bru b/backend/bruno/form-response/folder.bru new file mode 100644 index 0000000..d14185a --- /dev/null +++ b/backend/bruno/form-response/folder.bru @@ -0,0 +1,8 @@ +meta { + name: form-response + seq: 5 +} + +auth { + mode: inherit +} diff --git a/backend/bruno/form-response/getFormResponsesForFormOwner.bru b/backend/bruno/form-response/getFormResponsesForFormOwner.bru new file mode 100644 index 0000000..4be329f --- /dev/null +++ b/backend/bruno/form-response/getFormResponsesForFormOwner.bru @@ -0,0 +1,20 @@ +meta { + name: getFormResponsesForFormOwner + type: http + seq: 2 +} + +get { + url: http://localhost:8000/responses/:formId + body: none + auth: inherit +} + +params:path { + formId: ef50e45d-d095-418d-ab49-7196900fa5c2 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/backend/bruno/form-response/getSubmittedResponse.bru b/backend/bruno/form-response/getSubmittedResponse.bru new file mode 100644 index 0000000..aba6375 --- /dev/null +++ b/backend/bruno/form-response/getSubmittedResponse.bru @@ -0,0 +1,20 @@ +meta { + name: getSubmittedResponse + type: http + seq: 3 +} + +get { + url: http://localhost:8000/responses/:formId + body: none + auth: inherit +} + +params:path { + formId: ef50e45d-d095-418d-ab49-7196900fa5c2 +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/backend/bruno/form-response/submitResponse.bru b/backend/bruno/form-response/submitResponse.bru new file mode 100644 index 0000000..5a38c33 --- /dev/null +++ b/backend/bruno/form-response/submitResponse.bru @@ -0,0 +1,30 @@ +meta { + name: submitResponse + type: http + seq: 1 +} + +post { + url: http://localhost:8000/responses/:formId + body: json + auth: inherit +} + +params:path { + formId: ef50e45d-d095-418d-ab49-7196900fa5c2 +} + +body:json { + { + "answers":{ + "575f5192-d46c-4974-bb0c-f909d9fb80ce": "Jack", + "894b4cf0-1320-4403-b116-8504f23ef6e3": "jack@gmail.com", + "15026215-127b-44bc-8a06-a12e33f79802":"3" + } + } +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/backend/prisma/migrations/20260204153423_remove_child_field/migration.sql b/backend/prisma/migrations/20260204153423_remove_child_field/migration.sql new file mode 100644 index 0000000..8be99a2 --- /dev/null +++ b/backend/prisma/migrations/20260204153423_remove_child_field/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `nextField` on the `form_fields` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "form_fields" DROP COLUMN "nextField"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a9fe392..59797b5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -111,11 +111,6 @@ model FormFields { // This field stores the ID of the field passing control to this one prevFieldId String? - // The "Next" Field (Child) - // This field is the virtual counterpart to the relation above. - // We don't need a physical 'childFieldId' column because Prisma handles the other side of the relation automatically. - nextField String? - @@index([formId]) @@index([formId, prevFieldId]) @@map("form_fields") diff --git a/backend/src/api/form-fields/controller.ts b/backend/src/api/form-fields/controller.ts index dc093c4..f994621 100644 --- a/backend/src/api/form-fields/controller.ts +++ b/backend/src/api/form-fields/controller.ts @@ -23,16 +23,6 @@ export async function getAllFields({ params, set }: GetAllFieldsContext) { } const fields = await prisma.formFields.findMany({ - select: { - id: true, - fieldName: true, - label: true, - fieldValueType: true, - fieldType: true, - validation: true, - prevFieldId: true, - nextField: true, - }, where: { formId: params.formId }, }); @@ -44,14 +34,22 @@ export async function getAllFields({ params, set }: GetAllFieldsContext) { data: [], }; } - logger.info( - `Fetched all fields for formId: ${params.formId}, fieldCount: ${fields.length}`, + + const ordered: typeof fields = []; + + let current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === null, ); - return { - success: true, - message: "All form fields fetched successfully", - data: fields, - }; + + while (current) { + ordered.push(current); + + current = fields.find( + (f): f is (typeof fields)[number] => f.prevFieldId === current!.id, + ); + } + + return { success: true, data: ordered }; } export async function createField({ @@ -69,62 +67,66 @@ export async function createField({ if (!form) { set.status = 404; - return { - success: false, - message: "Form not found", - }; + return { success: false, message: "Form not found" }; } - const field = await prisma.$transaction(async (tx: any) => { - // 1. If we are inserting after a specific field (prevFieldId provided) - if (body.prevFieldId) { - // Fetch the previous field to see if it has a next field - const prevField = await tx.formFields.findUnique({ - where: { id: body.prevFieldId }, + const createdField = await prisma.$transaction(async (tx) => { + /** + * INSERT AT HEAD + */ + if (!body.prevFieldId) { + const currentHead = await tx.formFields.findFirst({ + where: { + formId: params.formId, + prevFieldId: null, + }, }); - if (!prevField) { - throw new Error("Previous field not found"); - } - - // 2. Create the new field - // It points back to prevFieldId - // It points forward to whatever prevField was pointing to - const newField = await tx.formFields.create({ + const created = await tx.formFields.create({ data: { fieldName: body.fieldName, label: body.label, fieldValueType: body.fieldValueType, fieldType: body.fieldType, validation: body.validation ?? undefined, - prevFieldId: body.prevFieldId, - nextField: prevField.nextField, // Inherit the link formId: params.formId, + prevFieldId: null, }, }); - // 3. Update the previous field to point to the new field - await tx.formFields.update({ - where: { id: body.prevFieldId }, - data: { nextField: newField.id }, - }); - - // 4. If there was a next field, update it to point back to the new field - if (prevField.nextField) { - // We can't query by `id` directly if `nextField` is just a string without relation, - // but typically we can update the row where id matches the string. + if (currentHead) { await tx.formFields.update({ - where: { id: prevField.nextField }, - data: { prevFieldId: newField.id }, + where: { id: currentHead.id }, + data: { prevFieldId: created.id }, }); } - return newField; + return created; } - // Fallback: If no prevFieldId is provided, we assume it's the first field - // or simply creating a field without links yet. - return await tx.formFields.create({ + /** + * INSERT AFTER A FIELD + */ + const prevField = await tx.formFields.findFirst({ + where: { + id: body.prevFieldId, + formId: params.formId, + }, + }); + + if (!prevField) { + // ❗ This will automatically rollback the transaction + throw new Error("Previous field not found in the specified form"); + } + + const nextField = await tx.formFields.findFirst({ + where: { + formId: params.formId, + prevFieldId: prevField.id, + }, + }); + + const created = await tx.formFields.create({ data: { fieldName: body.fieldName, label: body.label, @@ -132,16 +134,26 @@ export async function createField({ fieldType: body.fieldType, validation: body.validation ?? undefined, formId: params.formId, + prevFieldId: prevField.id, }, }); + + if (nextField) { + await tx.formFields.update({ + where: { id: nextField.id }, + data: { prevFieldId: created.id }, + }); + } + + return created; }); - logger.info(`Created field ${field.id} for form ${params.formId}`); + logger.info(`Created field ${createdField.id} in form ${params.formId}`); return { success: true, message: "Field created successfully", - data: field, + data: createdField, }; } @@ -202,26 +214,24 @@ export async function deleteField({ params, set, user }: DeleteFieldContext) { return { success: false, message: "Unauthorized" }; } - await prisma.$transaction(async (tx: any) => { - // 1. Link Previous to Next - if (field.prevFieldId) { - await tx.formFields.update({ - where: { id: field.prevFieldId }, - data: { nextField: field.nextField }, - }); - } + await prisma.$transaction(async (tx) => { + const nextField = await tx.formFields.findFirst({ + where: { + formId: field.formId, + prevFieldId: field.id, + }, + }); - // 2. Link Next to Previous - if (field.nextField) { + // relink previous → next + if (nextField) { await tx.formFields.update({ - where: { id: field.nextField }, + where: { id: nextField.id }, data: { prevFieldId: field.prevFieldId }, }); } - // 3. Delete the field await tx.formFields.delete({ - where: { id: params.id }, + where: { id: field.id }, }); }); diff --git a/backend/src/api/form-response/controller.ts b/backend/src/api/form-response/controller.ts new file mode 100644 index 0000000..b7c6906 --- /dev/null +++ b/backend/src/api/form-response/controller.ts @@ -0,0 +1,247 @@ +import { prisma } from "../../db/prisma"; +import { logger } from "../../logger/"; +import type { + FormResponseContext, + FormResponseForFormOwnerContext, + GetSubmittedResponseContext, + ResumeResponseContext, +} from "../../types/form-response"; + +export async function submitResponse({ + params, + body, + user, + set, +}: FormResponseContext) { + const form = await prisma.form.findUnique({ + where: { + id: params.formId, + }, + }); + + if (!form) { + logger.warn(`Form with ID ${params.formId} not found`); + set.status = 404; + return { + success: false, + message: "Form not found", + }; + } + + if (!form.isPublished) { + logger.warn(`Form with ID ${params.formId} is not published`); + set.status = 403; + return { + success: false, + message: "Form is not published", + }; + } + + const response = await prisma.formResponse.create({ + data: { + formId: params.formId, + respondentId: user.id, + answers: body.answers, + }, + }); + logger.info( + `User ${user.id} submitted response ${response.id} for form ${params.formId}`, + ); + return { + success: true, + message: "Response submitted successfully", + data: response, + }; +} + +export async function resumeResponse({ + params, + body, + user, +}: ResumeResponseContext) { + const response = await prisma.formResponse.updateMany({ + where: { + id: params.responseId, + respondentId: user.id, + }, + data: { + answers: body.answers, + }, + }); + + if (response.count === 0) { + logger.warn(`No response found with ID ${params.responseId} to update`); + return { + success: false, + message: "No response found to update", + }; + } + + logger.info(`Response ${params.responseId} updated successfully`); + return { + success: true, + message: "Response updated successfully", + data: response, + }; +} + +export async function getResponseForFormOwner({ + params, + user, + set, +}: FormResponseForFormOwnerContext) { + const form = await prisma.form.findUnique({ + where: { + id: params.formId, + ownerId: user.id, + }, + }); + + if (!form) { + logger.warn( + `Form with ID ${params.formId} not found or does not belong to user ${user.id}`, + ); + set.status = 404; + return { + success: false, + message: "Form not found or access denied", + }; + } + + const responses = await prisma.formResponse.findMany({ + where: { + formId: params.formId, + }, + select: { + id: true, + formId: true, + answers: true, + form: { + select: { + title: true, + }, + }, + }, + }); + if (responses.length === 0) { + logger.warn(`No responses found for form ID ${params.formId}`); + return { + success: false, + message: "No responses found for this form", + }; + } + + const fields = await prisma.formFields.findMany({ + where: { + formId: params.formId, + }, + select: { + id: true, + fieldName: true, + }, + }); + + const fieldIdToNameMap = Object.fromEntries( + fields.map((f) => [f.id, f.fieldName]), + ); + + const formattedResponses = responses.map((r) => { + 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, + answers: transformedAnswers, + }; + }); + + logger.info(`Retrieved responses for form ID ${params.formId}`); + return { + success: true, + message: "Responses retrieved successfully", + data: formattedResponses, + }; +} + +export async function getSubmittedResponse({ + params, + user, + set, +}: GetSubmittedResponseContext) { + const response = await prisma.formResponse.findMany({ + where: { + respondentId: user.id, + formId: params.formId, + }, + select: { + id: true, + formId: true, + answers: true, + form: { + select: { + title: true, + }, + }, + }, + }); + + if (response.length === 0) { + logger.warn( + `No response found for user ${user.id} on form ${params.formId}`, + ); + set.status = 404; + return { + success: false, + message: "No response found for this form", + }; + } + + const fields = await prisma.formFields.findMany({ + where: { + formId: params.formId, + }, + select: { + id: true, + fieldName: true, + }, + }); + + const fieldIdToNameMap = Object.fromEntries( + fields.map((f) => [f.id, f.fieldName]), + ); + + const formattedResponses = response.map((r) => { + 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, + answers: transformedAnswers, + }; + }); + + logger.info( + `Retrieved response for user ${user.id} on form ${params.formId}`, + ); + return { + success: true, + message: "Response retrieved successfully", + data: formattedResponses, + }; +} diff --git a/backend/src/api/form-response/routes.ts b/backend/src/api/form-response/routes.ts new file mode 100644 index 0000000..4e29856 --- /dev/null +++ b/backend/src/api/form-response/routes.ts @@ -0,0 +1,21 @@ +import { Elysia } from "elysia"; +import { + formResponseDTO, + formResponseForFormOwnerDTO, + getSubmittedResponseDTO, + resumeResponseDTO, +} from "../../types/form-response"; +import { requireAuth } from "../auth/requireAuth"; +import { + getResponseForFormOwner, + getSubmittedResponse, + resumeResponse, + submitResponse, +} from "./controller"; + +export const formResponseRoutes = new Elysia({ prefix: "/responses" }) + .use(requireAuth) + .post("/:formId", submitResponse, formResponseDTO) + .put("/resume/:responseId", resumeResponse, resumeResponseDTO) + .get("/:formId", getResponseForFormOwner, formResponseForFormOwnerDTO) + .get("/user/:formId", getSubmittedResponse, getSubmittedResponseDTO); diff --git a/backend/src/index.ts b/backend/src/index.ts index 08d1ecb..5c7392c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,7 @@ import { cors } from "@elysiajs/cors"; 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 { logger } from "./logger/index"; @@ -45,7 +46,8 @@ const app = new Elysia() .get("/", () => "🦊 Elysia server started") .use(authRoutes) .use(formRoutes) - .use(formFieldRoutes); + .use(formFieldRoutes) + .use(formResponseRoutes); app.listen(8000); diff --git a/backend/src/types/form-response.ts b/backend/src/types/form-response.ts new file mode 100644 index 0000000..db817c2 --- /dev/null +++ b/backend/src/types/form-response.ts @@ -0,0 +1,82 @@ +import { type Static, t } from "elysia"; + +export interface Context { + user: { id: string }; + set: { status?: number | string }; +} + +export const formResponseDTO = { + params: t.Object({ + formId: t.String({ + format: "uuid", + }), + }), + body: t.Object({ + answers: t.Record( + t.String(), // Key: The Field ID (UUID or String) + t.Union([ + // Value: Can be String, Number, Boolean, or Array (for checkboxes) + t.String(), + t.Number(), + t.Boolean(), + t.Array(t.String()), + t.Null(), + ]), + ), + }), +}; + +export interface FormResponseContext extends Context { + params: Static; + body: Static; +} + +export const resumeResponseDTO = { + params: t.Object({ + responseId: t.String({ + format: "uuid", + }), + }), + body: t.Object({ + answers: t.Record( + t.String(), // Key: The Field ID (UUID or String) + t.Union([ + // Value: Can be String, Number, Boolean, or Array (for checkboxes) + t.String(), + t.Number(), + t.Boolean(), + t.Array(t.String()), + t.Null(), + ]), + ), + }), +}; + +export interface ResumeResponseContext extends Context { + params: Static; + body: Static; +} + +export const formResponseForFormOwnerDTO = { + params: t.Object({ + formId: t.String({ + format: "uuid", + }), + }), +}; + +export interface FormResponseForFormOwnerContext extends Context { + params: Static; +} + +export const getSubmittedResponseDTO = { + params: t.Object({ + formId: t.String({ + format: "uuid", + }), + }), +}; + +export interface GetSubmittedResponseContext extends Context { + params: Static; +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 83d6b5f..0c205a4 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -79,7 +79,7 @@ /* Type Checking */ "strict": true /* Enable all strict type-checking options. */, - "noImplicitAny": false, /* Allow explicit 'any' types where needed. */ + "noImplicitAny": false /* Allow explicit 'any' types where needed. */, // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ diff --git a/backend/biome.json b/biome.json similarity index 73% rename from backend/biome.json rename to biome.json index 0bdabf3..65fb01c 100644 --- a/backend/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,7 +7,14 @@ }, "files": { "ignoreUnknown": true, - "includes": ["src/**"] + "includes": [ + "backend/src/**/*.{ts,js,json}", + "frontend/src/**/*.{ts,js,json,tsx,jsx}", + "backend/*.json", + "frontend/*.json", + "*.json", + ".github/**/*.yml" + ] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 402e495..e08ee2d 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "workspaces": { "": { "dependencies": { + "@biomejs/biome": "^2.3.14", "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.157.16", "@tanstack/react-router-devtools": "^1.157.16", @@ -55,6 +56,24 @@ "@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="], + "@biomejs/biome": ["@biomejs/biome@2.3.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.14", "@biomejs/cli-darwin-x64": "2.3.14", "@biomejs/cli-linux-arm64": "2.3.14", "@biomejs/cli-linux-arm64-musl": "2.3.14", "@biomejs/cli-linux-x64": "2.3.14", "@biomejs/cli-linux-x64-musl": "2.3.14", "@biomejs/cli-win32-arm64": "2.3.14", "@biomejs/cli-win32-x64": "2.3.14" }, "bin": { "biome": "bin/biome" } }, "sha512-QMT6QviX0WqXJCaiqVMiBUCr5WRQ1iFSjvOLoTk6auKukJMvnMzWucXpwZB0e8F00/1/BsS9DzcKgWH+CLqVuA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UJGPpvWJMkLxSRtpCAKfKh41Q4JJXisvxZL8ChN1eNW3m/WlPFJ6EFDCE7YfUb4XS8ZFi3C1dFpxUJ0Ety5n+A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-PNkLNQG6RLo8lG7QoWe/hhnMxJIt1tEimoXpGQjwS/dkdNiKBLPv4RpeQl8o3s1OKI3ZOR5XPiYtmbGGHAOnLA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-KT67FKfzIw6DNnUNdYlBg+eU24Go3n75GWK6NwU4+yJmDYFe9i/MjiI+U/iEzKvo0g7G7MZqoyrhIYuND2w8QQ=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-LInRbXhYujtL3sH2TMCH/UBwJZsoGwfQjBrMfl84CD4hL/41C/EU5mldqf1yoFpsI0iPWuU83U+nB2TUUypWeg=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-ZsZzQsl9U+wxFrGGS4f6UxREUlgHwmEfu1IrXlgNFrNnd5Th6lIJr8KmSzu/+meSa9f4rzFrbEW9LBBA6ScoMA=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-KQU7EkbBBuHPW3/rAcoiVmhlPtDSGOGRPv9js7qJVpYTzjQmVR+C9Rfcz+ti8YCH+zT1J52tuBybtP4IodjxZQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-+IKYkj/pUBbnRf1G1+RlyA3LWiDgra1xpS7H2g4BuOzzRbRB+hmlw0yFsLprHhbbt7jUzbzAbAjK/Pn0FDnh1A=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-oizCjdyQ3WJEswpb3Chdngeat56rIdSYK12JI3iI11Mt5T5EXcZ7WLuowzEaFPNJ3zmOQFliMN8QY1Pi+qsfdQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], diff --git a/package.json b/package.json index 86d2307..b0e7665 100644 --- a/package.json +++ b/package.json @@ -11,15 +11,19 @@ }, "lint-staged": { "backend/**/*.{ts,js,json}": [ - "bunx biome check --write", - "bunx biome check" + "bunx biome check --write --no-errors-on-unmatched" + ], + "frontend/**/*.{ts,tsx,js,jsx,json}": [ + "bunx biome format --write", + "bunx biome check --no-errors-on-unmatched --linter-enabled=false" ], ".github/**/*.yml": [ - "bunx biome check --write", - "bunx biome check" + "bunx biome format --write", + "bunx biome check --no-errors-on-unmatched" ] }, "dependencies": { + "@biomejs/biome": "^2.3.14", "@tanstack/react-query": "^5.90.20", "@tanstack/react-router": "^1.157.16", "@tanstack/react-router-devtools": "^1.157.16"