diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4f2e1a8d..d0342ca8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,13 +26,14 @@ model FeatureToggle { } model TeamMatchData { - key String @id + key String @id tournamentKey String - matchNumber Int @db.SmallInt + matchNumber Int @db.SmallInt teamNumber Int matchType MatchType scoutReports ScoutReport[] - tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) + formResponses FormResponse[] + tournament Tournament @relation(fields: [tournamentKey], references: [key], onDelete: Cascade) @@index([tournamentKey, teamNumber]) } @@ -99,6 +100,7 @@ model Scouter { scouterReliability Int @default(0) archived Boolean @default(false) scoutReports ScoutReport[] + formResponses FormResponse[] sourceTeam RegisteredTeam @relation(fields: [sourceTeamNumber], references: [number], onDelete: Cascade) team1Shifts ScouterScheduleShift[] @relation("Team1") team2Shifts ScouterScheduleShift[] @relation("Team2") @@ -137,6 +139,7 @@ model Team { number Int @id name String registeredTeam RegisteredTeam? + formResponses FormResponse[] } model RegisteredTeam { @@ -151,6 +154,7 @@ model RegisteredTeam { scouters Scouter[] scouterScheduleShifts ScouterScheduleShift[] slackChannels SlackWorkspace[] + customForms Form[] users User[] } @@ -233,6 +237,58 @@ model CachedAnalysis { tournamentDependencies String[] @default([]) } +model Form { + uuid String @id @default(uuid()) + name String + teamNumber Int + team RegisteredTeam @relation(fields: [teamNumber], references: [number], onDelete: Cascade) + formParts FormPart[] + formResponses FormResponse[] +} + +model FormPart { + uuid String @id @default(uuid()) + formUuid String + form Form @relation(fields: [formUuid], references: [uuid], onDelete: Cascade) + type FormPartType + caption String + name String + options Json @default("{}") + order Int + formResponseParts FormResponsePart[] +} + +model FormResponse { + uuid String @id @default(uuid()) + formUuid String + form Form @relation(fields: [formUuid], references: [uuid], onDelete: Cascade) + scouterUuid String + scouter Scouter @relation(fields: [scouterUuid], references: [uuid], onDelete: Cascade) + teamNumber Int? + team Team? @relation(fields: [teamNumber], references: [number], onDelete: Cascade) + teamMatchKey String? + teamMatchData TeamMatchData? @relation(fields: [teamMatchKey], references: [key], onDelete: Cascade) + formResponseParts FormResponsePart[] +} + +model FormResponsePart { + uuid String @id @default(uuid()) + formResponseUuid String + formResponse FormResponse @relation(fields: [formResponseUuid], references: [uuid], onDelete: Cascade) + formPartUuid String + formPart FormPart @relation(fields: [formPartUuid], references: [uuid], onDelete: Cascade) + response Json +} + +enum FormPartType { + RATING + SHORT_ANSWER + SINGLE_CHOICE + MULTIPLE_CHOICE + MULTIPLE_CHOICE_GRID + IMAGE +} + enum Position { LEFT_TRENCH LEFT_BUMP diff --git a/src/handler/analysis/coreAnalysis/averageForm.ts b/src/handler/analysis/coreAnalysis/averageForm.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/handler/analysis/csv/getFormCSV.ts b/src/handler/analysis/csv/getFormCSV.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/handler/analysis/teamLookUp/formDetails.ts b/src/handler/analysis/teamLookUp/formDetails.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/handler/analysis/teamLookUp/formMetrics.ts b/src/handler/analysis/teamLookUp/formMetrics.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/handler/manager/forms/createForm.ts b/src/handler/manager/forms/createForm.ts new file mode 100644 index 00000000..c97c1e0a --- /dev/null +++ b/src/handler/manager/forms/createForm.ts @@ -0,0 +1,58 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { FormPartType, UserRole } from "@prisma/client"; +import prismaClient from "../../../prismaClient.js"; +import { Response } from "express"; + +const createFormParamsSchema = z.object({ + name: z.string(), + parts: z.array( + z.object({ + name: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + options: z.record(z.string(), z.unknown()).optional(), + }), + ), +}); + +export const createForm = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = createFormParamsSchema.parse(req.body); + + const [form, formParts] = await prismaClient.$transaction(async (tx) => { + const form = await tx.form.create({ + data: { name: params.name, teamNumber: req.user.teamNumber }, + }); + const formParts = await tx.formPart.createManyAndReturn({ + data: params.parts.map((part, index) => ({ + name: part.name, + formUuid: form.uuid, + type: part.type, + caption: part.caption ?? "", + options: part.options ?? {}, + order: index, + })), + }); + return [form, formParts]; + }); + + res.status(200).json({ form, formParts }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error creating form:", error); + res + .status(500) + .json({ error: "An error occurred while creating the form." }); + } +}; diff --git a/src/handler/manager/forms/deleteForm.ts b/src/handler/manager/forms/deleteForm.ts new file mode 100644 index 00000000..10a17b06 --- /dev/null +++ b/src/handler/manager/forms/deleteForm.ts @@ -0,0 +1,43 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; +import { UserRole, Prisma } from "@prisma/client"; +import prismaClient from "../../../prismaClient"; +import { Response } from "express"; + +const deleteFormParamsSchema = z.object({ + uuid: z.string(), +}); + +export const deleteForm = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = deleteFormParamsSchema.parse(req.params); + + const form = await prismaClient.form.delete({ + where: { uuid: params.uuid, teamNumber: req.user.teamNumber }, + }); + + res.status(200).json({ form }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } else if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form not found" }); + return; + } + } else { + console.error("Error deleting form:", error); + res + .status(500) + .json({ error: "An error occurred while deleting the form." }); + } + } +}; diff --git a/src/handler/manager/forms/getForms.ts b/src/handler/manager/forms/getForms.ts new file mode 100644 index 00000000..c8193064 --- /dev/null +++ b/src/handler/manager/forms/getForms.ts @@ -0,0 +1,28 @@ +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; +import prismaClient from "../../../prismaClient"; +import { Response } from "express"; + +export const getForms = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const forms = await prismaClient.form.findMany({ + where: { + teamNumber: req.user.teamNumber, + }, + include: { + formParts: { + orderBy: { order: "asc" }, + }, + }, + }); + + res.status(200).json({ forms }); + } catch (error) { + console.error("Error getting forms:", error); + res + .status(500) + .json({ error: "An error occurred while getting the forms." }); + } +}; diff --git a/src/handler/manager/forms/parts/createFormPart.ts b/src/handler/manager/forms/parts/createFormPart.ts new file mode 100644 index 00000000..3d1a20c4 --- /dev/null +++ b/src/handler/manager/forms/parts/createFormPart.ts @@ -0,0 +1,75 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; +import { FormPartType, UserRole } from "@prisma/client"; + +const createFormPartParamsSchema = z.object({ + formUuid: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + name: z.string(), + options: z.record(z.string(), z.unknown()), + order: z.number(), +}); + +export const createFormPart = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = createFormPartParamsSchema.parse({ + formUuid: req.params.formUuid, + ...req.body, + }); + const form = await prismaClient.form.findUnique({ + where: { uuid: params.formUuid }, + }); + if (!form) { + res.status(404).json({ error: "Form not found" }); + return; + } + const formPart = await prismaClient.$transaction(async (tx) => { + const existing = await tx.formPart.findMany({ + where: { formUuid: params.formUuid }, + orderBy: { order: "asc" }, + }); + const formPart = await tx.formPart.create({ + data: { + formUuid: params.formUuid, + type: params.type, + caption: params.caption, + name: params.name, + options: params.options, + order: params.order, + }, + }); + const without = existing.filter((p) => p.uuid !== formPart.uuid); + without.splice(params.order, 0, formPart); + const reordered = without.map((p, index) => ({ ...p, order: index })); + await Promise.all( + reordered.map((p) => + tx.formPart.update({ + where: { uuid: p.uuid }, + data: { order: p.order }, + }), + ), + ); + return formPart; + }); + res.status(201).json({ formPart }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error creating form part:", error); + res + .status(500) + .json({ error: "An error occurred while creating the form part." }); + } +}; diff --git a/src/handler/manager/forms/parts/deleteFormPart.ts b/src/handler/manager/forms/parts/deleteFormPart.ts new file mode 100644 index 00000000..e695bc6a --- /dev/null +++ b/src/handler/manager/forms/parts/deleteFormPart.ts @@ -0,0 +1,56 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import { UserRole, Prisma } from "@prisma/client"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; + +const deleteFormPartParamsSchema = z.object({ + uuid: z.string(), +}); + +export const deleteFormPart = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = deleteFormPartParamsSchema.parse(req.params); + const existingFormPart = await prismaClient.formPart.findFirst({ + where: { + uuid: params.uuid, + form: { teamNumber: req.user.teamNumber }, + }, + }); + + if (!existingFormPart) { + res.status(404).json({ error: "Form part not found" }); + return; + } + + const formPart = await prismaClient.formPart.delete({ + where: { + uuid: existingFormPart.uuid, + }, + }); + + res.status(200).json({ formPart }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } else if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form part not found" }); + return; + } + } else { + console.error("Error deleting form part:", error); + res + .status(500) + .json({ error: "An error occurred while deleting the form part." }); + } + } +}; diff --git a/src/handler/manager/forms/parts/getFormParts.ts b/src/handler/manager/forms/parts/getFormParts.ts new file mode 100644 index 00000000..bb30bf40 --- /dev/null +++ b/src/handler/manager/forms/parts/getFormParts.ts @@ -0,0 +1,50 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; + +const getFormPartParamsSchema = z.object({ + uuid: z.string(), +}); + +export const getFormPart = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const params = getFormPartParamsSchema.parse(req.params); + + const formPart = await prismaClient.formPart.findUnique({ + where: { uuid: params.uuid }, + include: { + formResponseParts: { + orderBy: { formResponseUuid: "asc" }, + }, + form: { + select: { teamNumber: true }, + }, + }, + }); + + if (!formPart) { + res.status(404).json({ error: "Form part not found" }); + return; + } + + if (formPart.form.teamNumber !== req.user.teamNumber) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + res.status(200).json({ formPart }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error getting form parts:", error); + res + .status(500) + .json({ error: "An error occurred while getting the form parts." }); + } +}; diff --git a/src/handler/manager/forms/parts/reorderFormParts.ts b/src/handler/manager/forms/parts/reorderFormParts.ts new file mode 100644 index 00000000..a8040540 --- /dev/null +++ b/src/handler/manager/forms/parts/reorderFormParts.ts @@ -0,0 +1,85 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; +import { Prisma, UserRole } from "@prisma/client"; + +const updateFormPartParamsSchema = z.object({ + formUuid: z.string(), + uuid: z.string(), + order: z.number(), +}); + +export const reorderFormParts = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = updateFormPartParamsSchema.parse({ + uuid: req.params.uuid, + formUuid: req.params.formUuid, + ...req.body, + }); + const formPart = await prismaClient.$transaction(async (tx) => { + const form = await tx.form.findUnique({ + where: { uuid: params.formUuid }, + }); + if (!form) { + throw new Error("Form not found"); + } + if (form.teamNumber !== req.user.teamNumber) { + throw new Error("Forbidden"); + } + const formParts = await tx.formPart.findMany({ + where: { formUuid: params.formUuid }, + orderBy: { order: "asc" }, + }); + const target = formParts.find((p) => p.uuid === params.uuid); + if (!target) { + throw new Error("Form part not found"); + } + const without = formParts.filter((p) => p.uuid !== params.uuid); + without.splice(params.order, 0, target); + const reordered = without.map((p, index) => ({ ...p, order: index })); + const updatedFormParts = await Promise.all( + reordered.map((p) => + tx.formPart.update({ + where: { uuid: p.uuid }, + data: { order: p.order }, + }), + ), + ); + return updatedFormParts.find((p) => p.uuid === params.uuid); + }); + res.status(200).json({ formPart }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } else if (error instanceof Error) { + if (error.message === "Form not found") { + res.status(404).json({ error: "Form not found" }); + return; + } else if (error.message === "Forbidden") { + res.status(403).json({ error: "Forbidden" }); + return; + } else if (error.message === "Form part not found") { + res.status(404).json({ error: "Form part not found" }); + return; + } + } else if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form part not found" }); + return; + } + } + console.error("Error reordering form part:", error); + res + .status(500) + .json({ error: "An error occurred while reordering the form part." }); + } +}; diff --git a/src/handler/manager/forms/parts/updateFormPart.ts b/src/handler/manager/forms/parts/updateFormPart.ts new file mode 100644 index 00000000..38988af9 --- /dev/null +++ b/src/handler/manager/forms/parts/updateFormPart.ts @@ -0,0 +1,65 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; +import { FormPartType, Prisma, UserRole } from "@prisma/client"; + +const updateFormPartParamsSchema = z.object({ + uuid: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + name: z.string(), + options: z.record(z.string(), z.unknown()), +}); + +export const updateFormPart = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = updateFormPartParamsSchema.parse({ + uuid: req.params.uuid, + ...req.body, + }); + const existing = await prismaClient.formPart.findUnique({ + where: { uuid: params.uuid }, + include: { form: true }, + }); + if (!existing) { + res.status(404).json({ error: "Form part not found" }); + return; + } + if (existing.form.teamNumber !== req.user.teamNumber) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const formPart = await prismaClient.formPart.update({ + where: { uuid: params.uuid }, + data: { + type: params.type, + caption: params.caption, + name: params.name, + options: params.options, + }, + }); + res.status(200).json({ formPart }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } else if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form part not found" }); + return; + } + } + console.error("Error updating form part:", error); + res + .status(500) + .json({ error: "An error occurred while updating the form part." }); + } +}; diff --git a/src/handler/manager/forms/responses/deleteResponse.ts b/src/handler/manager/forms/responses/deleteResponse.ts new file mode 100644 index 00000000..de352331 --- /dev/null +++ b/src/handler/manager/forms/responses/deleteResponse.ts @@ -0,0 +1,70 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import { UserRole, Prisma } from "@prisma/client"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; + +const deleteFormResponseParamsSchema = z.object({ + formUuid: z.string(), + responseUuid: z.string(), +}); + +export const deleteFormResponse = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== UserRole.SCOUTING_LEAD) { + res.status(403).json({ error: "Forbidden" }); + return; + } + const params = deleteFormResponseParamsSchema.parse(req.params); + + const existingFormResponse = await prismaClient.formResponse.findFirst({ + where: { + uuid: params.responseUuid, + form: { teamNumber: req.user.teamNumber }, + }, + include: { + form: true, + }, + }); + + if (!existingFormResponse) { + res.status(404).json({ error: "Form response not found" }); + return; + } + + if (existingFormResponse.formUuid !== params.formUuid) { + res + .status(400) + .json({ error: "Form response does not belong to the specified form" }); + return; + } + + if (existingFormResponse.form.teamNumber !== req.user.teamNumber) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const form = await prismaClient.formResponse.delete({ + where: { uuid: params.responseUuid }, + }); + + res.status(200).json({ form }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } else if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form response not found" }); + return; + } + } + console.error("Error deleting form response:", error); + res + .status(500) + .json({ error: "An error occurred while deleting the form response." }); + } +}; diff --git a/src/handler/manager/forms/responses/getResponse.ts b/src/handler/manager/forms/responses/getResponse.ts new file mode 100644 index 00000000..aa1d565f --- /dev/null +++ b/src/handler/manager/forms/responses/getResponse.ts @@ -0,0 +1,48 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; + +const getResponseParamsSchema = z.object({ + responseUuid: z.string(), +}); + +export const getFormResponse = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const params = getResponseParamsSchema.parse(req.params); + + const formResponse = await prismaClient.formResponse.findUnique({ + where: { + uuid: params.responseUuid, + form: { teamNumber: req.user.teamNumber }, + }, + include: { + formResponseParts: { + orderBy: { formPartUuid: "asc" }, + }, + scouter: { + select: { name: true }, + }, + }, + }); + + if (!formResponse) { + res.status(404).json({ error: "Form response not found" }); + return; + } + + res.status(200).json({ formResponse }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error getting form response:", error); + res + .status(500) + .json({ error: "An error occurred while getting the form response." }); + } +}; diff --git a/src/handler/manager/forms/responses/getResponses.ts b/src/handler/manager/forms/responses/getResponses.ts new file mode 100644 index 00000000..a7090cb6 --- /dev/null +++ b/src/handler/manager/forms/responses/getResponses.ts @@ -0,0 +1,49 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; + +const getResponsesParamsSchema = z.object({ + formUuid: z.string(), +}); + +export const getFormResponses = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const params = getResponsesParamsSchema.parse(req.params); + + const form = await prismaClient.form.findUnique({ + where: { uuid: params.formUuid, teamNumber: req.user.teamNumber }, + include: { + formResponses: { + include: { + formResponseParts: { + orderBy: { formPartUuid: "asc" }, + }, + scouter: { + select: { name: true }, + }, + }, + }, + }, + }); + + if (!form) { + res.status(404).json({ error: "Form not found" }); + return; + } + + res.status(200).json({ form }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error getting form responses:", error); + res + .status(500) + .json({ error: "An error occurred while getting the form responses." }); + } +}; diff --git a/src/handler/manager/forms/responses/submitForm.ts b/src/handler/manager/forms/responses/submitForm.ts new file mode 100644 index 00000000..334c1300 --- /dev/null +++ b/src/handler/manager/forms/responses/submitForm.ts @@ -0,0 +1,85 @@ +import z from "zod"; +import { Prisma } from "@prisma/client"; +import prismaClient from "../../../../prismaClient"; +import { Request, Response } from "express"; + +const submitFormParamsSchema = z.object({ + team: z.number().optional(), + scouterUuid: z.string(), + matchKey: z.string().optional(), + formUuid: z.string(), + parts: z.array( + z.object({ + formPartUuid: z.string(), + response: z.union([z.string(), z.number(), z.array(z.string())]), + }), + ), +}); + +export const submitForm = async ( + req: Request, + res: Response, +): Promise => { + try { + const params = submitFormParamsSchema.parse({ + formUuid: req.params.formUuid, + ...req.body, + }); + + if (!params.matchKey && !params.team) { + res.status(400).json({ error: "Either matchKey or team is required" }); + return; + } + + const scouter = await prismaClient.scouter.findUnique({ + where: { uuid: params.scouterUuid }, + }); + + if (!scouter) { + res.status(404).json({ error: "Scouter not found" }); + return; + } + + const formResponseCreateData: Prisma.FormResponseCreateInput = + params.matchKey + ? { + teamMatchData: { connect: { key: params.matchKey } }, + scouter: { connect: { uuid: params.scouterUuid } }, + form: { connect: { uuid: params.formUuid } }, + } + : { + team: { connect: { number: params.team } }, + scouter: { connect: { uuid: params.scouterUuid } }, + form: { connect: { uuid: params.formUuid } }, + }; + + const [formResponse, formResponseParts] = await prismaClient.$transaction( + async (tx) => { + const formResponse = await tx.formResponse.create({ + data: formResponseCreateData, + }); + const formResponseParts = await tx.formResponsePart.createManyAndReturn( + { + data: params.parts.map((part) => ({ + formResponseUuid: formResponse.uuid, + formPartUuid: part.formPartUuid, + response: part.response, + })), + }, + ); + return [formResponse, formResponseParts]; + }, + ); + + res.status(200).json({ formResponse, formResponseParts }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error submitting form:", error); + res + .status(500) + .json({ error: "An error occurred while submitting the form." }); + } +}; diff --git a/src/handler/manager/forms/responses/submitFormDashboard.ts b/src/handler/manager/forms/responses/submitFormDashboard.ts new file mode 100644 index 00000000..335e7e22 --- /dev/null +++ b/src/handler/manager/forms/responses/submitFormDashboard.ts @@ -0,0 +1,86 @@ +import z from "zod"; +import { Prisma } from "@prisma/client"; +import prismaClient from "../../../../prismaClient"; +import { Response } from "express"; +import { AuthenticatedRequest } from "../../../../lib/middleware/requireAuth"; + +const submitFormDashboardParamsSchema = z.object({ + team: z.number().optional(), + scouterUuid: z.string(), + matchKey: z.string().optional(), + formUuid: z.string(), + parts: z.array( + z.object({ + formPartUuid: z.string(), + response: z.union([z.string(), z.number(), z.array(z.string())]), + }), + ), +}); + +export const submitFormDashboard = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const params = submitFormDashboardParamsSchema.parse({ + formUuid: req.params.formUuid, + ...req.body, + }); + + if (!params.matchKey && !params.team) { + res.status(400).json({ error: "Either matchKey or team is required" }); + return; + } + + const scouter = await prismaClient.scouter.findUnique({ + where: { uuid: params.scouterUuid }, + }); + + if (!scouter) { + res.status(404).json({ error: "Scouter not found" }); + return; + } + + const formResponseCreateData: Prisma.FormResponseCreateInput = + params.matchKey + ? { + teamMatchData: { connect: { key: params.matchKey } }, + scouter: { connect: { uuid: params.scouterUuid } }, + form: { connect: { uuid: params.formUuid } }, + } + : { + team: { connect: { number: params.team } }, + scouter: { connect: { uuid: params.scouterUuid } }, + form: { connect: { uuid: params.formUuid } }, + }; + + const [formResponse, formResponseParts] = await prismaClient.$transaction( + async (tx) => { + const formResponse = await tx.formResponse.create({ + data: formResponseCreateData, + }); + const formResponseParts = await tx.formResponsePart.createManyAndReturn( + { + data: params.parts.map((part) => ({ + formResponseUuid: formResponse.uuid, + formPartUuid: part.formPartUuid, + response: part.response, + })), + }, + ); + return [formResponse, formResponseParts]; + }, + ); + + res.status(200).json({ formResponse, formResponseParts }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid input", details: error }); + return; + } + console.error("Error submitting form:", error); + res + .status(500) + .json({ error: "An error occurred while submitting the form." }); + } +}; diff --git a/src/handler/manager/forms/responses/uploadImage.ts b/src/handler/manager/forms/responses/uploadImage.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/handler/manager/forms/updateFormName.ts b/src/handler/manager/forms/updateFormName.ts new file mode 100644 index 00000000..00b7edfc --- /dev/null +++ b/src/handler/manager/forms/updateFormName.ts @@ -0,0 +1,50 @@ +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth"; +import prismaClient from "../../../prismaClient"; +import { Response } from "express"; +import { Prisma } from "@prisma/client"; + +const updateFormNameParamsSchema = z.object({ + formUuid: z.string(), + name: z.string(), +}); + +export const updateFormName = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.user.role !== "SCOUTING_LEAD") { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const params = updateFormNameParamsSchema.safeParse({ + formUuid: req.params.formUuid, + name: req.body.name, + }); + + if (!params.success) { + res.status(400).json({ error: "Invalid input", details: params.error }); + return; + } + + const form = await prismaClient.form.update({ + where: { uuid: params.data.formUuid, teamNumber: req.user.teamNumber }, + data: { name: params.data.name }, + }); + + res.status(200).json({ form }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2025") { + res.status(404).json({ error: "Form not found" }); + return; + } + } + console.error("Error updating form name:", error); + res + .status(500) + .json({ error: "An error occurred while updating the form name." }); + } +}; diff --git a/src/routes/manager/forms.routes.ts b/src/routes/manager/forms.routes.ts new file mode 100644 index 00000000..122fbd0a --- /dev/null +++ b/src/routes/manager/forms.routes.ts @@ -0,0 +1,344 @@ +import { Router } from "express"; +import { requireAuth } from "../../lib/middleware/requireAuth.js"; +import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; +import { registry } from "../../lib/openapi.js"; +import { z } from "zod"; +import { createForm } from "../../handler/manager/forms/createForm.js"; +import { deleteForm } from "../../handler/manager/forms/deleteForm.js"; +import { getForms } from "../../handler/manager/forms/getForms.js"; +import { updateFormName } from "../../handler/manager/forms/updateFormName.js"; +import { createFormPart } from "../../handler/manager/forms/parts/createFormPart.js"; +import { deleteFormPart } from "../../handler/manager/forms/parts/deleteFormPart.js"; +import { getFormPart } from "../../handler/manager/forms/parts/getFormParts.js"; +import { updateFormPart } from "../../handler/manager/forms/parts/updateFormPart.js"; +import { reorderFormParts } from "../../handler/manager/forms/parts/reorderFormParts.js"; +import { deleteFormResponse } from "../../handler/manager/forms/responses/deleteResponse.js"; +import { getFormResponse } from "../../handler/manager/forms/responses/getResponse.js"; +import { getFormResponses } from "../../handler/manager/forms/responses/getResponses.js"; +import { submitForm } from "../../handler/manager/forms/responses/submitForm.js"; +import { FormPartType } from "@prisma/client"; + +// Forms +registry.registerPath({ + method: "post", + path: "/v1/manager/forms", + tags: ["Manager - Forms"], + summary: "Create a new form", + request: { + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string(), + parts: z.array( + z.object({ + name: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + options: z.record(z.string(), z.unknown()).optional(), + }), + ), + }), + }, + }, + }, + }, + responses: { + 200: { description: "Created" }, + 400: { description: "Invalid request parameters" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "delete", + path: "/v1/manager/forms/:uuid", + tags: ["Manager - Forms"], + summary: "Delete a form", + request: { params: z.object({ uuid: z.string() }) }, + responses: { + 200: { description: "Deleted" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/forms", + tags: ["Manager - Forms"], + summary: "List forms for the authenticated team", + responses: { + 200: { description: "Forms" }, + 401: { description: "Unauthorized" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "put", + path: "/v1/manager/forms/:formUuid", + tags: ["Manager - Forms"], + summary: "Update form name", + request: { + params: z.object({ formUuid: z.string() }), + body: { + content: { + "application/json": { schema: z.object({ name: z.string() }) }, + }, + }, + }, + responses: { + 200: { description: "Updated" }, + 400: { description: "Invalid request parameters" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +// Form parts +registry.registerPath({ + method: "post", + path: "/v1/manager/forms/:formUuid/parts", + tags: ["Manager - Form Parts"], + summary: "Add a part to a form", + request: { + params: z.object({ formUuid: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + options: z.record(z.string(), z.unknown()), + order: z.number(), + }), + }, + }, + }, + }, + responses: { + 201: { description: "Created" }, + 400: { description: "Invalid request parameters" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "delete", + path: "/v1/manager/forms/:formUuid/parts/:uuid", + tags: ["Manager - Form Parts"], + summary: "Delete a form part", + request: { + params: z.object({ formUuid: z.string(), uuid: z.string() }), + }, + responses: { + 200: { description: "Deleted" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form part not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/forms/:formUuid/parts/:uuid", + tags: ["Manager - Form Parts"], + summary: "Get a form part with its responses", + request: { + params: z.object({ formUuid: z.string(), uuid: z.string() }), + }, + responses: { + 200: { description: "Form part" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form part not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "put", + path: "/v1/manager/forms/:formUuid/parts/:uuid", + tags: ["Manager - Form Parts"], + summary: "Update a form part", + request: { + params: z.object({ formUuid: z.string(), uuid: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + name: z.string(), + type: z.nativeEnum(FormPartType), + caption: z.string(), + options: z.record(z.string(), z.unknown()), + }), + }, + }, + }, + }, + responses: { + 200: { description: "Updated" }, + 400: { description: "Invalid request parameters" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form part not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "put", + path: "/v1/manager/forms/:formUuid/parts/:uuid/reorder", + tags: ["Manager - Form Parts"], + summary: "Reorder a form part", + request: { + params: z.object({ formUuid: z.string(), uuid: z.string() }), + body: { + content: { + "application/json": { schema: z.object({ order: z.number() }) }, + }, + }, + }, + responses: { + 200: { description: "Reordered" }, + 400: { description: "Invalid request parameters" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form part not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +// Form responses +registry.registerPath({ + method: "post", + path: "/v1/manager/forms/:formUuid/responses", + tags: ["Manager - Form Responses"], + summary: "Submit a form response", + request: { + params: z.object({ formUuid: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ + team: z.number().optional(), + scouterUuid: z.string(), + matchKey: z.string().optional(), + parts: z.array( + z.object({ + formPartUuid: z.string(), + response: z.union([ + z.string(), + z.number(), + z.array(z.string()), + ]), + }), + ), + }), + }, + }, + }, + }, + responses: { + 200: { description: "Submitted" }, + 400: { description: "Invalid request parameters" }, + 500: { description: "Server error" }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/forms/:formUuid/responses", + tags: ["Manager - Form Responses"], + summary: "Get all responses for a form", + request: { params: z.object({ formUuid: z.string() }) }, + responses: { + 200: { description: "Form responses" }, + 401: { description: "Unauthorized" }, + 404: { description: "Form not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/forms/:formUuid/responses/:responseUuid", + tags: ["Manager - Form Responses"], + summary: "Get a single form response", + request: { + params: z.object({ formUuid: z.string(), responseUuid: z.string() }), + }, + responses: { + 200: { description: "Form response" }, + 401: { description: "Unauthorized" }, + 404: { description: "Form response not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "delete", + path: "/v1/manager/forms/:formUuid/responses/:responseUuid", + tags: ["Manager - Form Responses"], + summary: "Delete a form response", + request: { + params: z.object({ formUuid: z.string(), responseUuid: z.string() }), + }, + responses: { + 200: { description: "Deleted" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Form response not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +const router = Router(); + +router.post("/:formUuid/responses", submitForm); + +router.use(requireAuth, requireVerifiedTeam); + +// Forms +router.post("/", createForm); +router.delete("/:uuid", deleteForm); +router.get("/", getForms); +router.put("/:formUuid", updateFormName); + +// Form parts +router.post("/:formUuid/parts", createFormPart); +router.delete("/:formUuid/parts/:uuid", deleteFormPart); +router.get("/:formUuid/parts/:uuid", getFormPart); +router.put("/:formUuid/parts/:uuid", updateFormPart); +router.put("/:formUuid/parts/:uuid/reorder", reorderFormParts); + +// Form responses — submitForm is unauthenticated +router.get("/:formUuid/responses", getFormResponses); +router.get("/:formUuid/responses/:responseUuid", getFormResponse); +router.delete("/:formUuid/responses/:responseUuid", deleteFormResponse); + +export default router; diff --git a/src/routes/manager/manager.routes.ts b/src/routes/manager/manager.routes.ts index 6291cca1..a493b207 100644 --- a/src/routes/manager/manager.routes.ts +++ b/src/routes/manager/manager.routes.ts @@ -10,6 +10,7 @@ import scoutreports from "./scoutreports.routes.js"; import scoutershifts from "./scoutershifts.routes.js"; import settings from "./settings.routes.js"; import apikey from "./apikey.routes.js"; +import forms from "./forms.routes.js"; import { getTournaments } from "../../handler/manager/getTournaments.js"; import { getTeams } from "../../handler/manager/getTeams.js"; @@ -414,6 +415,7 @@ router.use("/tournament", tournaments); router.use("/scoutreports", scoutreports); router.use("/settings", settings); router.use("/apikey", apikey); +router.use("/forms", forms); router.get("/teams", requireAuth, getTeams); router.get("/tournaments", requireAuth, getTournaments);