From ca0b1a98349c372e427cf96dbbb703d22874a51b Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Sat, 1 Aug 2026 21:24:28 -0700 Subject: [PATCH 1/7] Add custom fields feature Let scouting leads define custom post-match questions (text, number, single-select, multi-select) that scouts answer in Lovat Collection and that surface across the dashboard's analysis, scoped per source team. - New CustomField / CustomFieldAnswer models + Zod/OpenAPI mirrors - Lead-only CRUD plus a public x-team-code manifest endpoint for offline caching - Optional customFieldAnswers on both scout-report submission handlers (lenient: unknown/wrong-team/out-of-options answers dropped, report kept) - Answers flow into raw report data, both CSV exports, category metrics + detail sparklines (number), breakdown distributions (select), and picklist z-scores (number) via a cf_ key convention - Per-viewer cache isolation so custom data never leaks across teams - Fully additive/backwards-compatible Co-Authored-By: Claude Fable 5 --- prisma/schema.prisma | 40 ++ src/handler/analysis/analysisHandler.ts | 52 ++- src/handler/analysis/csv/getReportCSV.ts | 57 ++- src/handler/analysis/csv/getTeamCSV.ts | 66 ++- .../customFields/customFieldNumberAverages.ts | 391 ++++++++++++++++++ .../customFieldSelectDistribution.ts | 164 ++++++++ .../customFields/customFieldShared.ts | 183 ++++++++ .../analysis/picklist/applyCustomZScores.ts | 77 ++++ .../analysis/picklist/picklistShell.ts | 109 ++++- .../matchPageSpecificScouter.ts | 16 + .../analysis/teamLookUp/breakdownDetails.ts | 130 +++++- .../analysis/teamLookUp/breakdownMetrics.ts | 63 +++ .../analysis/teamLookUp/categoryMetrics.ts | 55 +++ .../analysis/teamLookUp/detailsPage.ts | 79 +++- .../manager/customfields/addCustomField.ts | 118 ++++++ .../customfields/archiveCustomField.ts | 82 ++++ .../manager/customfields/deleteCustomField.ts | 93 +++++ .../manager/customfields/getCustomFields.ts | 49 +++ .../customfields/getCustomFieldsManifest.ts | 56 +++ .../customfields/reorderCustomFields.ts | 87 ++++ .../customfields/unarchiveCustomField.ts | 102 +++++ .../manager/customfields/updateCustomField.ts | 122 ++++++ .../validateCustomFieldAnswers.ts | 118 ++++++ src/handler/manager/picklists/addPicklist.ts | 5 + .../manager/picklists/getSinglePicklist.ts | 1 + .../manager/picklists/updatePicklist.ts | 7 + .../manager/scoutreports/addScoutReport.ts | 24 +- .../scoutreports/addScoutReportDashboard.ts | 22 + .../manager/scoutreports/getScoutReport.ts | 16 +- src/lib/prisma-zod.ts | 29 ++ src/routes/analysis/analysis.routes.ts | 6 + src/routes/manager/customfields.routes.ts | 237 +++++++++++ src/routes/manager/manager.routes.ts | 4 + src/routes/manager/picklists.routes.ts | 4 + src/routes/manager/scoutreports.routes.ts | 16 + 35 files changed, 2640 insertions(+), 40 deletions(-) create mode 100644 src/handler/analysis/customFields/customFieldNumberAverages.ts create mode 100644 src/handler/analysis/customFields/customFieldSelectDistribution.ts create mode 100644 src/handler/analysis/customFields/customFieldShared.ts create mode 100644 src/handler/analysis/picklist/applyCustomZScores.ts create mode 100644 src/handler/manager/customfields/addCustomField.ts create mode 100644 src/handler/manager/customfields/archiveCustomField.ts create mode 100644 src/handler/manager/customfields/deleteCustomField.ts create mode 100644 src/handler/manager/customfields/getCustomFields.ts create mode 100644 src/handler/manager/customfields/getCustomFieldsManifest.ts create mode 100644 src/handler/manager/customfields/reorderCustomFields.ts create mode 100644 src/handler/manager/customfields/unarchiveCustomField.ts create mode 100644 src/handler/manager/customfields/updateCustomField.ts create mode 100644 src/handler/manager/customfields/validateCustomFieldAnswers.ts create mode 100644 src/routes/manager/customfields.routes.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0402a70..c3fa9956 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,37 @@ model ScoutReport { disrupts Boolean endgameClimb EndgameClimb autoClimb AutoClimb + + customFieldAnswers CustomFieldAnswer[] +} + +model CustomField { + uuid String @id @default(uuid()) + teamNumber Int + name String + type CustomFieldType + options String[] @default([]) + order Int + archived Boolean @default(false) + createdAt DateTime @default(now()) + sourceTeam RegisteredTeam @relation(fields: [teamNumber], references: [number], onDelete: Cascade) + answers CustomFieldAnswer[] + + @@index([teamNumber, archived]) +} + +model CustomFieldAnswer { + uuid String @id @default(uuid()) + scoutReportUuid String + fieldUuid String + textValue String? + numberValue Float? + selections String[] @default([]) + scoutReport ScoutReport @relation(fields: [scoutReportUuid], references: [uuid], onDelete: Cascade) + field CustomField @relation(fields: [fieldUuid], references: [uuid], onDelete: Cascade) + + @@unique([scoutReportUuid, fieldUuid]) + @@index([fieldUuid]) } model ScouterScheduleShift { @@ -131,6 +162,7 @@ model SharedPicklist { totalDefensiveTime Float totalFuelFed Float totalFuelThroughput Float + customFieldWeights Json @default("{}") author User @relation(fields: [authorId], references: [id], onDelete: Cascade) } @@ -153,6 +185,7 @@ model RegisteredTeam { scouterScheduleShifts ScouterScheduleShift[] slackChannels SlackWorkspace[] users User[] + customFields CustomField[] } model EmailVerificationRequest { @@ -335,3 +368,10 @@ enum MatchType { QUALIFICATION ELIMINATION } + +enum CustomFieldType { + TEXT + NUMBER + SINGLE_SELECT + MULTI_SELECT +} diff --git a/src/handler/analysis/analysisHandler.ts b/src/handler/analysis/analysisHandler.ts index d4e7947d..de1fb119 100644 --- a/src/handler/analysis/analysisHandler.ts +++ b/src/handler/analysis/analysisHandler.ts @@ -34,11 +34,21 @@ export type AnalysisHandlerArgs< params: AnalysisHandlerParamsSchema; createKey: ( params: AnalysisHandlerParams, + ctx: AnalysisContext, ) => Promise | CreateKeyResult; calculateAnalysis: ( params: AnalysisHandlerParams, ctx: AnalysisContext, ) => Promise; + // Optional hook run after the cache read on both hit and miss paths (and on + // the shouldCache:false path) whenever the result has no .error. Its return + // value is sent to the client but is NEVER written to the cache — the cache + // always stores the raw calculated result. + augmentResponse?: ( + params: AnalysisHandlerParams, + ctx: AnalysisContext, + result: any, + ) => Promise | any; usesDataSource: boolean; shouldCache: boolean; }; @@ -75,7 +85,16 @@ export const createAnalysisHandler: < let calculatedAnalysis = null; calculatedAnalysis = await args.calculateAnalysis(params, context); - res.status(200).send(calculatedAnalysis.error ?? calculatedAnalysis); + let responseBody = calculatedAnalysis.error ?? calculatedAnalysis; + if (!calculatedAnalysis.error && args.augmentResponse) { + responseBody = await args.augmentResponse( + params, + context, + calculatedAnalysis, + ); + } + + res.status(200).send(responseBody); } catch (error) { res.status(500).send("Error calculating analysis"); console.error(error); @@ -89,7 +108,7 @@ export const createAnalysisHandler: < key: keyFragments, teamDependencies: teamDeps, tournamentDependencies: tournamentDeps, - } = await args.createKey(params); + } = await args.createKey(params, context); const teamSourceRule = dataSourceRuleSchema(z.number()).parse( context.dataSource.teams, @@ -119,8 +138,17 @@ export const createAnalysisHandler: < context, ); + let responseBody = calculatedAnalysis.error ?? calculatedAnalysis; + if (!calculatedAnalysis.error && args.augmentResponse) { + responseBody = await args.augmentResponse( + params, + context, + calculatedAnalysis, + ); + } + res.set("X-Lovat-Cache", "miss"); - res.status(200).send(calculatedAnalysis.error ?? calculatedAnalysis); + res.status(200).send(responseBody); try { await kv.set(key, JSON.stringify(calculatedAnalysis)); @@ -147,13 +175,19 @@ export const createAnalysisHandler: < return; } } else { - res.set("X-Lovat-Cache", "hit"); - res - .status(200) - .send( - JSON.parse(cacheRow.toString()).error ?? - JSON.parse(cacheRow.toString()), + const cachedAnalysis = JSON.parse(cacheRow.toString()); + + let responseBody = cachedAnalysis.error ?? cachedAnalysis; + if (!cachedAnalysis.error && args.augmentResponse) { + responseBody = await args.augmentResponse( + params, + context, + cachedAnalysis, ); + } + + res.set("X-Lovat-Cache", "hit"); + res.status(200).send(responseBody); } } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/handler/analysis/csv/getReportCSV.ts b/src/handler/analysis/csv/getReportCSV.ts index fbdc6933..be699083 100644 --- a/src/handler/analysis/csv/getReportCSV.ts +++ b/src/handler/analysis/csv/getReportCSV.ts @@ -23,6 +23,12 @@ import { dataSourceRuleSchema, } from "../dataSourceRule.js"; import { averageScoutReport } from "../coreAnalysis/averageScoutReport.js"; +import { + buildCustomColumnLabels, + formatAnswerForCsv, + getActiveCustomFields, + sanitizeCsv, +} from "../customFields/customFieldShared.js"; // Scouting report condensed into a single dimension that can be pushed to a row in the csv export interface CondensedReport { @@ -238,11 +244,58 @@ export const getReportCSV = async ( ), ); + // Append one column per active custom field (all types) after the notes + // key, in field order, on EVERY row so Object.keys stays stable. Values + // are blank for reports from other teams or predating the field. + let rows: object[] = condensed; + if (req.user.teamNumber !== null && req.user.teamNumber !== undefined) { + const customFields = await getActiveCustomFields(req.user.teamNumber); + if (customFields.length > 0) { + const labels = buildCustomColumnLabels( + customFields.map((field) => ({ + uuid: field.uuid, + name: sanitizeCsv(field.name), + })), + ); + + const answers = await prismaClient.customFieldAnswer.findMany({ + where: { + scoutReportUuid: { in: datapoints.map((r) => r.uuid) }, + fieldUuid: { in: customFields.map((field) => field.uuid) }, + }, + }); + + const answersByReport = new Map< + string, + Map + >(); + for (const answer of answers) { + if (!answersByReport.has(answer.scoutReportUuid)) { + answersByReport.set(answer.scoutReportUuid, new Map()); + } + answersByReport.get(answer.scoutReportUuid).set(answer.fieldUuid, answer); + } + + // condensed is index-parallel to datapoints + rows = condensed.map((row, i) => { + const reportAnswers = answersByReport.get(datapoints[i].uuid); + const withCustom: Record = { ...row }; + for (const field of customFields) { + withCustom[`${labels[field.uuid]} (Custom)`] = formatAnswerForCsv( + field, + reportAnswers?.get(field.uuid) ?? null, + ); + } + return withCustom; + }); + } + } + // Create and send the csv string through express - const csvString = stringify(condensed, { + const csvString = stringify(rows, { header: true, // Creates column headers from data properties - columns: condensed.length ? Object.keys(condensed[0]) : [], + columns: rows.length ? Object.keys(rows[0]) : [], // Required for excel viewing bom: true, // Rename boolean values to TRUE and FALSE diff --git a/src/handler/analysis/csv/getTeamCSV.ts b/src/handler/analysis/csv/getTeamCSV.ts index 7092934c..844ad3b1 100644 --- a/src/handler/analysis/csv/getTeamCSV.ts +++ b/src/handler/analysis/csv/getTeamCSV.ts @@ -14,6 +14,8 @@ import { ClimbSide, FeederType, IntakeType, + CustomFieldType, + User, } from "@prisma/client"; import { autoEnd, endgameToPoints, Metric } from "../analysisConstants.js"; import { z } from "zod"; @@ -22,6 +24,12 @@ import { dataSourceRuleSchema, } from "../dataSourceRule.js"; import { averageManyFast } from "../coreAnalysis/averageManyFast.js"; +import { customFieldNumberManyFast } from "../customFields/customFieldNumberAverages.js"; +import { + buildCustomColumnLabels, + getActiveCustomFields, + sanitizeCsv, +} from "../customFields/customFieldShared.js"; interface AggregatedTeamData { teamNumber: number; @@ -365,11 +373,11 @@ export const getTeamCSV = async ( }), ); - const csvString = stringify(aggregatedData, { + const rows = await appendCustomFieldColumns(req.user, aggregatedData); + + const csvString = stringify(rows, { header: true, - columns: aggregatedData.length - ? Object.keys(aggregatedData[0]) - : [], + columns: rows.length ? Object.keys(rows[0]) : [], bom: true, cast: { boolean: (b) => (b ? "TRUE" : "FALSE"), @@ -459,10 +467,12 @@ export const getTeamCSV = async ( ); }), ); - const csvString = stringify(aggregatedData, { + const rows = await appendCustomFieldColumns(req.user, aggregatedData); + + const csvString = stringify(rows, { header: true, // Creates column headers from data properties - columns: aggregatedData.length ? Object.keys(aggregatedData[0]) : [], + columns: rows.length ? Object.keys(rows[0]) : [], // Required for excel viewing bom: true, // Rename boolean values to TRUE and FALSE @@ -483,6 +493,50 @@ export const getTeamCSV = async ( } }; +/** + * Appends one "Avg (Custom)" column per active custom NUMBER field for + * the viewer's team, on every row so Object.keys stays stable. Cells are blank + * when a team has no answers for the field. Rows are returned unchanged when + * the viewer has no team or no active NUMBER fields. + */ +async function appendCustomFieldColumns( + user: User, + aggregatedData: AggregatedTeamData[], +): Promise { + if (user?.teamNumber === null || user?.teamNumber === undefined) { + return aggregatedData; + } + + const customFields = await getActiveCustomFields(user.teamNumber, [ + CustomFieldType.NUMBER, + ]); + if (customFields.length === 0) { + return aggregatedData; + } + + const labels = buildCustomColumnLabels( + customFields.map((field) => ({ + uuid: field.uuid, + name: sanitizeCsv(field.name), + })), + ); + + const averages = await customFieldNumberManyFast(user, { + viewerTeam: user.teamNumber, + teams: aggregatedData.map((row) => row.teamNumber), + fieldUuids: customFields.map((field) => field.uuid), + }); + + return aggregatedData.map((row) => { + const withCustom: Record = { ...row }; + for (const field of customFields) { + const average = averages[field.uuid]?.[String(row.teamNumber)] ?? null; + withCustom[`Avg ${labels[field.uuid]} (Custom)`] = average ?? ""; + } + return withCustom; + }); +} + async function aggregateTeamReports( teamNum: number, numMatches: number, diff --git a/src/handler/analysis/customFields/customFieldNumberAverages.ts b/src/handler/analysis/customFields/customFieldNumberAverages.ts new file mode 100644 index 00000000..00c2cfd0 --- /dev/null +++ b/src/handler/analysis/customFields/customFieldNumberAverages.ts @@ -0,0 +1,391 @@ +import { CustomFieldType, User } from "@prisma/client"; +import z from "zod"; +import prismaClient from "../../../prismaClient.js"; +import { allTeamNumbers } from "../analysisConstants.js"; +import { runAnalysis, AnalysisFunctionConfig } from "../analysisFunction.js"; +import { avg } from "../coreAnalysis/averageManyFast.js"; +import { weightedTourAvgLeft } from "../coreAnalysis/arrayAndAverageTeams.js"; +import { + getTournamentSourceRule, + teamSourceRuleAllowsOwnTeam, + tournamentRuleToSqlCondition, +} from "./customFieldShared.js"; + +/** + * Custom NUMBER field aggregates. All functions are viewer-scoped: answers are + * only ever read from reports submitted by the viewer's own team + * (sourceTeamNumber = viewerTeam), fields must belong to viewerTeam, and + * viewerTeam is part of every cache key and its teamDependencies. + * Aggregation matches existing metrics: per-match AVG across reports, then + * per-tournament AVG, then weightedTourAvgLeft across tournaments + * (oldest first). + */ + +type MatchAvgRow = { + fieldUuid: string; + teamNumber: number; + tournamentKey: string; + tournamentName: string; + matchKey: string; + matchAvg: number; +}; + +// Per-match averages for the given fields/teams, ordered oldest tournament +// first (then match order within each tournament) +const fetchMatchAverages = async ( + user: User, + fieldUuids: string[], + viewerTeam: number, + teams: number[] | null, +): Promise => { + const tournamentRule = getTournamentSourceRule(user); + const tournamentCondition = tournamentRuleToSqlCondition( + tournamentRule, + `tmd."tournamentKey"`, + teams === null ? 3 : 4, + ); + + const teamCondition = + teams === null ? "" : `AND tmd."teamNumber" = ANY($3::int[])`; + + const query = ` + SELECT a."fieldUuid", + tmd."teamNumber", + tmd."tournamentKey", + t."name" AS "tournamentName", + tmd."key" AS "matchKey", + AVG(a."numberValue")::float AS "matchAvg" + FROM "CustomFieldAnswer" a + JOIN "ScoutReport" sr ON sr."uuid" = a."scoutReportUuid" + JOIN "Scouter" sc ON sc."uuid" = sr."scouterUuid" + JOIN "TeamMatchData" tmd ON tmd."key" = sr."teamMatchKey" + JOIN "Tournament" t ON t."key" = tmd."tournamentKey" + WHERE a."fieldUuid" = ANY($1::text[]) + AND a."numberValue" IS NOT NULL + AND sc."sourceTeamNumber" = $2 + ${teamCondition} + AND ${tournamentCondition.clause} + GROUP BY a."fieldUuid", tmd."teamNumber", tmd."tournamentKey", t."name", t."date", tmd."key", tmd."matchType", tmd."matchNumber" + ORDER BY t."date" ASC, tmd."tournamentKey" ASC, tmd."teamNumber" ASC, tmd."matchType" ASC, tmd."matchNumber" ASC + `; + + const params: unknown[] = + teams === null + ? [fieldUuids, viewerTeam, tournamentCondition.param] + : [fieldUuids, viewerTeam, teams, tournamentCondition.param]; + + return prismaClient.$queryRawUnsafe(query, ...params); +}; + +// Owned NUMBER-field uuids among the requested ones (archived included so +// direct cf_ lookups keep working) +const verifyOwnedNumberFields = async ( + fieldUuids: string[], + viewerTeam: number, +): Promise> => { + if (fieldUuids.length === 0) return new Set(); + const fields = await prismaClient.customField.findMany({ + where: { + uuid: { in: fieldUuids }, + teamNumber: viewerTeam, + type: CustomFieldType.NUMBER, + }, + select: { uuid: true }, + }); + return new Set(fields.map((field) => field.uuid)); +}; + +const dedupe = (teams: number[]): number[] => Array.from(new Set(teams)); + +/* ------------------- customFieldNumberManyFast ------------------- */ + +const manyFastArgsSchema = z.object({ + viewerTeam: z.number(), + teams: z.array(z.number()), + fieldUuids: z.array(z.string()), +}); + +const manyFastReturnSchema = z.record( + z.string(), + z.record(z.string(), z.number().nullable()), +); + +const manyFastConfig: AnalysisFunctionConfig< + typeof manyFastArgsSchema, + typeof manyFastReturnSchema +> = { + argsSchema: manyFastArgsSchema, + returnSchema: manyFastReturnSchema, + usesDataSource: true, + shouldCache: true, + + createKey: (args) => ({ + key: [ + "customFieldNumberManyFast", + String(args.viewerTeam), + JSON.stringify([...args.teams].sort((a, b) => a - b)), + JSON.stringify([...args.fieldUuids].sort()), + ], + teamDependencies: dedupe([args.viewerTeam, ...args.teams]), + tournamentDependencies: [], + }), + + calculateAnalysis: async (args, ctx) => { + // Stable shape: every requested field key present, every team null-seeded + const result: Record> = {}; + for (const fieldUuid of args.fieldUuids) { + result[fieldUuid] = {}; + for (const team of args.teams) { + result[fieldUuid][String(team)] = null; + } + } + + if ( + ctx.user.teamNumber !== args.viewerTeam || + !teamSourceRuleAllowsOwnTeam(ctx.user) + ) { + return result; + } + + const ownedUuids = await verifyOwnedNumberFields( + args.fieldUuids, + args.viewerTeam, + ); + if (ownedUuids.size === 0 || args.teams.length === 0) return result; + + const rows = await fetchMatchAverages( + ctx.user, + Array.from(ownedUuids), + args.viewerTeam, + args.teams, + ); + + // field -> team -> tournament -> match values (tournaments oldest first) + const grouped: Record>> = {}; + for (const row of rows) { + if (!ownedUuids.has(row.fieldUuid)) continue; + grouped[row.fieldUuid] ??= {}; + grouped[row.fieldUuid][row.teamNumber] ??= new Map(); + const byTournament = grouped[row.fieldUuid][row.teamNumber]; + if (!byTournament.has(row.tournamentKey)) { + byTournament.set(row.tournamentKey, []); + } + byTournament.get(row.tournamentKey).push(row.matchAvg); + } + + for (const fieldUuid of Object.keys(grouped)) { + for (const team of args.teams) { + const byTournament = grouped[fieldUuid][team]; + if (!byTournament) continue; + const tournamentAverages = Array.from(byTournament.values()).map(avg); + if (tournamentAverages.length > 0) { + result[fieldUuid][String(team)] = + weightedTourAvgLeft(tournamentAverages); + } + } + } + + return result; + }, +}; + +export type CustomFieldNumberManyFastResult = z.infer< + typeof manyFastReturnSchema +>; + +/** + * Weighted tournament averages for many teams x many custom NUMBER fields. + * Returns { [fieldUuid]: { [teamNumber]: average | null } }; null when the + * team has no answers for the field (or the field isn't the viewer's). + */ +export const customFieldNumberManyFast = async ( + user: User, + args: z.infer, +): Promise => + runAnalysis(manyFastConfig, user, args); + +/* -------------------- customFieldNumberTeams --------------------- */ + +const teamsArgsSchema = z.object({ + viewerTeam: z.number(), + teams: z.array(z.number()), + fieldUuid: z.string(), +}); + +const teamsReturnSchema = z.record( + z.string(), + z.object({ + average: z.number().nullable(), + timeLine: z.array( + z.object({ + match: z.string(), + dataPoint: z.number(), + tournamentName: z.string(), + }), + ), + }), +); + +const teamsConfig: AnalysisFunctionConfig< + typeof teamsArgsSchema, + typeof teamsReturnSchema +> = { + argsSchema: teamsArgsSchema, + returnSchema: teamsReturnSchema, + usesDataSource: true, + shouldCache: true, + + createKey: (args) => ({ + key: [ + "customFieldNumberTeams", + String(args.viewerTeam), + JSON.stringify([...args.teams].sort((a, b) => a - b)), + args.fieldUuid, + ], + teamDependencies: dedupe([args.viewerTeam, ...args.teams]), + tournamentDependencies: [], + }), + + calculateAnalysis: async (args, ctx) => { + const result: z.infer = {}; + for (const team of args.teams) { + result[String(team)] = { average: null, timeLine: [] }; + } + + if ( + ctx.user.teamNumber !== args.viewerTeam || + !teamSourceRuleAllowsOwnTeam(ctx.user) + ) { + return result; + } + + const ownedUuids = await verifyOwnedNumberFields( + [args.fieldUuid], + args.viewerTeam, + ); + if (!ownedUuids.has(args.fieldUuid) || args.teams.length === 0) { + return result; + } + + const rows = await fetchMatchAverages( + ctx.user, + [args.fieldUuid], + args.viewerTeam, + args.teams, + ); + + // team -> tournament -> match values (tournaments oldest first) + const grouped: Record> = {}; + for (const row of rows) { + grouped[row.teamNumber] ??= new Map(); + const byTournament = grouped[row.teamNumber]; + if (!byTournament.has(row.tournamentKey)) { + byTournament.set(row.tournamentKey, []); + } + byTournament.get(row.tournamentKey).push(row.matchAvg); + + result[String(row.teamNumber)]?.timeLine.push({ + match: row.matchKey, + dataPoint: row.matchAvg, + tournamentName: row.tournamentName, + }); + } + + for (const team of args.teams) { + const byTournament = grouped[team]; + if (!byTournament) continue; + const tournamentAverages = Array.from(byTournament.values()).map(avg); + if (tournamentAverages.length > 0) { + result[String(team)].average = weightedTourAvgLeft(tournamentAverages); + } + } + + return result; + }, +}; + +export type CustomFieldNumberTeamsResult = z.infer; + +/** + * Per-team weighted average + match timeline for one custom NUMBER field + * (archived fields resolve so old links keep working). Shape mirrors + * arrayAndAverageTeams: { [teamNumber]: { average, timeLine } }. + */ +export const customFieldNumberTeams = async ( + user: User, + args: z.infer, +): Promise => + runAnalysis(teamsConfig, user, args); + +/* --------------------- customFieldNumberAll ---------------------- */ + +const allArgsSchema = z.object({ + viewerTeam: z.number(), + fieldUuid: z.string(), +}); + +const allReturnSchema = z.number().nullable(); + +const allConfig: AnalysisFunctionConfig< + typeof allArgsSchema, + typeof allReturnSchema +> = { + argsSchema: allArgsSchema, + returnSchema: allReturnSchema, + usesDataSource: true, + shouldCache: true, + + createKey: async (args) => ({ + key: ["customFieldNumberAll", String(args.viewerTeam), args.fieldUuid], + teamDependencies: dedupe([args.viewerTeam, ...(await allTeamNumbers)]), + tournamentDependencies: [], + }), + + calculateAnalysis: async (args, ctx) => { + if ( + ctx.user.teamNumber !== args.viewerTeam || + !teamSourceRuleAllowsOwnTeam(ctx.user) + ) { + return null; + } + + const ownedUuids = await verifyOwnedNumberFields( + [args.fieldUuid], + args.viewerTeam, + ); + if (!ownedUuids.has(args.fieldUuid)) return null; + + const rows = await fetchMatchAverages( + ctx.user, + [args.fieldUuid], + args.viewerTeam, + null, // all teams + ); + + if (rows.length === 0) return null; + + // tournament -> match values across all teams (tournaments oldest first) + const byTournament = new Map(); + for (const row of rows) { + if (!byTournament.has(row.tournamentKey)) { + byTournament.set(row.tournamentKey, []); + } + byTournament.get(row.tournamentKey).push(row.matchAvg); + } + + const tournamentAverages = Array.from(byTournament.values()).map(avg); + return tournamentAverages.length > 0 + ? weightedTourAvgLeft(tournamentAverages) + : null; + }, +}; + +/** + * Weighted average of one custom NUMBER field across every scouted team + * (the "all" comparison value on details pages). null when no data or the + * field isn't the viewer's. + */ +export const customFieldNumberAll = async ( + user: User, + args: z.infer, +): Promise => runAnalysis(allConfig, user, args); diff --git a/src/handler/analysis/customFields/customFieldSelectDistribution.ts b/src/handler/analysis/customFields/customFieldSelectDistribution.ts new file mode 100644 index 00000000..44f96e73 --- /dev/null +++ b/src/handler/analysis/customFields/customFieldSelectDistribution.ts @@ -0,0 +1,164 @@ +import { CustomFieldType, User } from "@prisma/client"; +import z from "zod"; +import prismaClient from "../../../prismaClient.js"; +import { runAnalysis, AnalysisFunctionConfig } from "../analysisFunction.js"; +import { + getActiveCustomFields, + getTournamentSourceRule, + teamSourceRuleAllowsOwnTeam, + tournamentRuleToSqlCondition, +} from "./customFieldShared.js"; + +/** + * Breakdown-style distribution of the viewer's active SINGLE_SELECT and + * MULTI_SELECT custom fields for one scouted team. Viewer-scoped: only reports + * submitted by the viewer's own team count, and viewerTeam is part of the + * cache key and its teamDependencies. + * + * Percentages: denominator is the number of answered reports for the field — + * SINGLE_SELECT sums to 1, MULTI_SELECT may exceed 1. All of the field's + * current options are seeded at 0; stale selections (options since removed + * from the field) are still included under their original value. + */ + +const argsSchema = z.object({ + viewerTeam: z.number(), + team: z.number(), +}); + +const returnSchema = z.object({ + fields: z.array( + z.object({ + uuid: z.string(), + name: z.string(), + order: z.number(), + type: z.nativeEnum(CustomFieldType), + options: z.array(z.string()), + answerCount: z.number(), + percentages: z.record(z.string(), z.number()), + }), + ), +}); + +type SelectionRow = { + fieldUuid: string; + selection: string; + selectionCount: number; + answerCount: number; +}; + +const config: AnalysisFunctionConfig = { + argsSchema, + returnSchema, + usesDataSource: true, + shouldCache: true, + + createKey: (args) => ({ + key: [ + "customFieldSelectDistribution", + String(args.viewerTeam), + String(args.team), + ], + teamDependencies: Array.from(new Set([args.viewerTeam, args.team])), + tournamentDependencies: [], + }), + + calculateAnalysis: async (args, ctx) => { + if ( + ctx.user.teamNumber !== args.viewerTeam || + !teamSourceRuleAllowsOwnTeam(ctx.user) + ) { + return { fields: [] }; + } + + // Ownership: only the viewer team's own active select fields + const fields = await getActiveCustomFields(args.viewerTeam, [ + CustomFieldType.SINGLE_SELECT, + CustomFieldType.MULTI_SELECT, + ]); + if (fields.length === 0) return { fields: [] }; + + const tournamentRule = getTournamentSourceRule(ctx.user); + const tournamentCondition = tournamentRuleToSqlCondition( + tournamentRule, + `tmd."tournamentKey"`, + 4, + ); + + const query = ` + WITH filtered AS ( + SELECT a."fieldUuid", a."selections" + FROM "CustomFieldAnswer" a + JOIN "ScoutReport" sr ON sr."uuid" = a."scoutReportUuid" + JOIN "Scouter" sc ON sc."uuid" = sr."scouterUuid" + JOIN "TeamMatchData" tmd ON tmd."key" = sr."teamMatchKey" + WHERE a."fieldUuid" = ANY($1::text[]) + AND sc."sourceTeamNumber" = $2 + AND tmd."teamNumber" = $3 + AND cardinality(a."selections") > 0 + AND ${tournamentCondition.clause} + ), + counts AS ( + SELECT "fieldUuid", COUNT(*)::float AS "answerCount" + FROM filtered + GROUP BY "fieldUuid" + ) + SELECT f."fieldUuid", + sel.value AS "selection", + COUNT(*)::float AS "selectionCount", + c."answerCount" + FROM filtered f + CROSS JOIN UNNEST(f."selections") AS sel(value) + JOIN counts c ON c."fieldUuid" = f."fieldUuid" + GROUP BY f."fieldUuid", sel.value, c."answerCount" + `; + + const rows = await prismaClient.$queryRawUnsafe( + query, + fields.map((field) => field.uuid), + args.viewerTeam, + args.team, + tournamentCondition.param, + ); + + const rowsByField: Record = {}; + for (const row of rows) { + (rowsByField[row.fieldUuid] ??= []).push(row); + } + + return { + fields: fields.map((field) => { + const fieldRows = rowsByField[field.uuid] ?? []; + const answerCount = fieldRows.length > 0 ? fieldRows[0].answerCount : 0; + + // Seed all current options at 0 so every option shows up + const percentages: Record = {}; + for (const option of field.options) { + percentages[option] = 0; + } + for (const row of fieldRows) { + percentages[row.selection] = + answerCount > 0 ? row.selectionCount / answerCount : 0; + } + + return { + uuid: field.uuid, + name: field.name, + order: field.order, + type: field.type, + options: field.options, + answerCount: answerCount, + percentages: percentages, + }; + }), + }; + }, +}; + +export type CustomFieldSelectDistributionResult = z.infer; + +export const customFieldSelectDistribution = async ( + user: User, + args: z.infer, +): Promise => + runAnalysis(config, user, args); diff --git a/src/handler/analysis/customFields/customFieldShared.ts b/src/handler/analysis/customFields/customFieldShared.ts new file mode 100644 index 00000000..1870656a --- /dev/null +++ b/src/handler/analysis/customFields/customFieldShared.ts @@ -0,0 +1,183 @@ +import { CustomField, CustomFieldType, User } from "@prisma/client"; +import z from "zod"; +import prismaClient from "../../../prismaClient.js"; +import { dataSourceRuleSchema, DataSourceRule } from "../dataSourceRule.js"; + +// Metric key convention for custom fields, used everywhere a metric path is +// accepted (categories, details, breakdowns, picklist weights): cf_ +export const CF_PREFIX = "cf_"; + +export const cfKey = (fieldUuid: string): string => `${CF_PREFIX}${fieldUuid}`; + +/** Returns the field uuid if the key is a cf_ metric key, otherwise null */ +export const parseCfKey = (key: string): string | null => { + if (typeof key !== "string" || !key.startsWith(CF_PREFIX)) return null; + const uuid = key.slice(CF_PREFIX.length); + return uuid.length > 0 ? uuid : null; +}; + +/** + * Custom data is only ever computed from reports submitted by the viewer's own + * team. If the viewer's team source rule excludes their own team (or they have + * no team), every custom surface must be empty. + */ +export const teamSourceRuleAllowsOwnTeam = (user: User): boolean => { + if (user?.teamNumber === null || user?.teamNumber === undefined) return false; + const parsed = dataSourceRuleSchema(z.number()).safeParse( + user.teamSourceRule, + ); + if (!parsed.success) return false; + if (parsed.data.mode === "INCLUDE") { + return parsed.data.items.includes(user.teamNumber); + } + return !parsed.data.items.includes(user.teamNumber); +}; + +/** + * Active (non-archived) custom fields for a team, optionally filtered by type, + * in canonical display order. + */ +export const getActiveCustomFields = async ( + teamNumber: number, + types?: CustomFieldType[], +): Promise => { + return prismaClient.customField.findMany({ + where: { + teamNumber: teamNumber, + archived: false, + ...(types ? { type: { in: types } } : {}), + }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }, { uuid: "asc" }], + }); +}; + +// Shape of a custom field answer as exposed on raw-report responses +// (reconciliation #6) +export type CustomFieldAnswerView = { + fieldUuid: string; + name: string; + type: CustomFieldType; + options: string[]; + order: number; + archived: boolean; + textValue: string | null; + numberValue: number | null; + selections: string[]; +}; + +/** + * All stored answers for one scout report (archived fields included, flagged), + * sorted by the field's canonical order. Only answered fields are returned. + */ +export const getAnswersForReport = async ( + scoutReportUuid: string, +): Promise => { + const answers = await prismaClient.customFieldAnswer.findMany({ + where: { scoutReportUuid: scoutReportUuid }, + include: { field: true }, + }); + + answers.sort((a, b) => { + if (a.field.order !== b.field.order) return a.field.order - b.field.order; + const timeDiff = + a.field.createdAt.getTime() - b.field.createdAt.getTime(); + if (timeDiff !== 0) return timeDiff; + return a.field.uuid < b.field.uuid ? -1 : a.field.uuid > b.field.uuid ? 1 : 0; + }); + + return answers.map((answer) => ({ + fieldUuid: answer.fieldUuid, + name: answer.field.name, + type: answer.field.type, + options: answer.field.options, + order: answer.field.order, + archived: answer.field.archived, + textValue: answer.textValue, + numberValue: answer.numberValue, + selections: answer.selections, + })); +}; + +/** + * CSV output is generated with quoting disabled, so free-form values must not + * contain commas or newlines. + */ +export const sanitizeCsv = (value: string): string => { + return value + .replace(/,/g, ";") + .replace(/\r\n/g, " ") + .replace(/[\r\n]/g, " "); +}; + +/** + * Builds a uuid -> label map for CSV columns, de-duplicating repeated field + * names with " 2"/" 3" suffixes (first occurrence keeps the bare name). + * Fields should be passed in canonical display order. + */ +export const buildCustomColumnLabels = ( + fields: { uuid: string; name: string }[], +): Record => { + const labels: Record = {}; + const seenCounts: Record = {}; + for (const field of fields) { + const count = (seenCounts[field.name] ?? 0) + 1; + seenCounts[field.name] = count; + labels[field.uuid] = count === 1 ? field.name : `${field.name} ${count}`; + } + return labels; +}; + +/** Formats one answer for a CSV cell; blank when unanswered */ +export const formatAnswerForCsv = ( + field: { type: CustomFieldType }, + answer?: { + textValue: string | null; + numberValue: number | null; + selections: string[]; + } | null, +): string => { + if (!answer) return ""; + switch (field.type) { + case CustomFieldType.TEXT: + return answer.textValue !== null && answer.textValue !== undefined + ? sanitizeCsv(answer.textValue) + : ""; + case CustomFieldType.NUMBER: + return answer.numberValue !== null && answer.numberValue !== undefined + ? String(answer.numberValue) + : ""; + case CustomFieldType.SINGLE_SELECT: + case CustomFieldType.MULTI_SELECT: + return answer.selections.map(sanitizeCsv).join("|"); + default: + return ""; + } +}; + +/** + * INCLUDE/EXCLUDE tournament source rule as a positional-parameter SQL + * condition, matching how existing raw-SQL analysis functions apply it + * (e.g. nonEventMetric): INCLUDE -> `col = ANY($n)`, EXCLUDE -> `col != ALL($n)`. + * The rule's items array must be bound at position `paramIndex`. + */ +export const tournamentRuleToSqlCondition = ( + rule: DataSourceRule, + col: string, + paramIndex: number, +): { clause: string; param: string[] } => { + return { + clause: + rule.mode === "INCLUDE" + ? `${col} = ANY($${paramIndex}::text[])` + : `${col} != ALL($${paramIndex}::text[])`, + param: rule.items, + }; +}; + +/** Parses the viewer's tournament source rule (same as existing raw-SQL users) */ +export const getTournamentSourceRule = (user: User): DataSourceRule => { + const parsed = dataSourceRuleSchema(z.string()).safeParse( + user?.tournamentSourceRule, + ); + return parsed.success ? parsed.data : { mode: "INCLUDE", items: [] }; +}; diff --git a/src/handler/analysis/picklist/applyCustomZScores.ts b/src/handler/analysis/picklist/applyCustomZScores.ts new file mode 100644 index 00000000..45206fea --- /dev/null +++ b/src/handler/analysis/picklist/applyCustomZScores.ts @@ -0,0 +1,77 @@ +import { defaultSTD } from "../analysisConstants.js"; +import { CustomFieldNumberManyFastResult } from "../customFields/customFieldNumberAverages.js"; +import { cfKey } from "../customFields/customFieldShared.js"; + +type PicklistEntry = { + team: number; + result: number; + breakdown: { type: string; result: number }[]; + unweighted: { type: string; result: number }[]; + flags: { type: string; result: number }[]; +}; + +/** + * Sibling of zScoreMany for custom NUMBER field picklist weights. For each + * weighted field, computes each team's z-score (mean and population standard + * deviation over teams with a non-null average; null averages contribute a + * z-score of 0; defaultSTD fallback like zScoreMany), then pushes + * { type: "cf_", result } entries onto every team's breakdown (weighted) + * and unweighted arrays and adds the weighted score into the team's total. + * Must run BEFORE the final sort — mutates `results` in place. + * + * @param results output of zScoreMany (totals already summed) + * @param teams team numbers included in the picklist + * @param averages customFieldNumberManyFast output: fieldUuid -> team -> avg|null + * @param weights fieldUuid -> non-zero weight + */ +export const applyCustomZScores = ( + results: PicklistEntry[], + teams: number[], + averages: CustomFieldNumberManyFastResult, + weights: Record, +): void => { + const entriesByTeam = new Map(); + for (const entry of results) { + entriesByTeam.set(entry.team, entry); + } + + for (const fieldUuid of Object.keys(weights)) { + const perTeam = averages[fieldUuid] ?? {}; + + // Mean and population standard deviation over non-null values only + const nonNullValues: number[] = []; + for (const team of teams) { + const value = perTeam[String(team)]; + if (typeof value === "number") nonNullValues.push(value); + } + + let mean = 0; + let std = 0; + if (nonNullValues.length > 0) { + mean = + nonNullValues.reduce((acc, cur) => acc + cur, 0) / nonNullValues.length; + const variance = + nonNullValues.reduce( + (acc, cur) => acc + (cur - mean) * (cur - mean), + 0, + ) / nonNullValues.length; + std = Math.sqrt(variance); + } + + const metricKey = cfKey(fieldUuid); + for (const team of teams) { + const entry = entriesByTeam.get(team); + if (!entry) continue; + + const value = perTeam[String(team)]; + // Teams without data (null) contribute a z-score of 0 + const zScore = + typeof value === "number" ? (value - mean) / (std || defaultSTD) : 0; + const weighted = zScore * weights[fieldUuid]; + + entry.breakdown.push({ type: metricKey, result: weighted }); + entry.unweighted.push({ type: metricKey, result: zScore }); + entry.result += weighted; + } + } +}; diff --git a/src/handler/analysis/picklist/picklistShell.ts b/src/handler/analysis/picklist/picklistShell.ts index 7575527d..0d658369 100644 --- a/src/handler/analysis/picklist/picklistShell.ts +++ b/src/handler/analysis/picklist/picklistShell.ts @@ -1,5 +1,6 @@ import prismaClient from "../../../prismaClient.js"; import z from "zod"; +import { CustomFieldType } from "@prisma/client"; import { addTournamentMatches } from "../../manager/addTournamentMatches.js"; import { Metric, @@ -10,6 +11,33 @@ import { import { averageManyFast } from "../coreAnalysis/averageManyFast.js"; import { zScoreMany } from "./zScoreMany.js"; import { createAnalysisHandler } from "../analysisHandler.js"; +import { applyCustomZScores } from "./applyCustomZScores.js"; +import { customFieldNumberManyFast } from "../customFields/customFieldNumberAverages.js"; +import { + getActiveCustomFields, + parseCfKey, +} from "../customFields/customFieldShared.js"; + +// Defensive parse of the customWeights JSON-object query param: anything that +// is not a finite, non-zero number keyed by a cf_ metric key is dropped. +const parseCustomWeights = (val: string): Record => { + try { + const parsed = JSON.parse(val); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return {}; + } + const weights: Record = {}; + for (const [key, weight] of Object.entries(parsed)) { + if (parseCfKey(key) === null) continue; + if (typeof weight !== "number" || !Number.isFinite(weight)) continue; + if (weight === 0) continue; + weights[key] = weight; + } + return weights; + } catch { + return {}; + } +}; /** * Main picklist endpoint. Note inconsistent strings make it confusing for this season. * Normal metrics are sent and received in lettering suggested by query inputs, but FLAGS are sent and received as shown in metricToName. @@ -47,11 +75,12 @@ export const picklistShell = createAnalysisHandler({ scoringRate: z.coerce.number().optional(), estimatedSuccessfulFuelRate: z.coerce.number().optional(), estimatedTotalFuelScored: z.coerce.number().optional(), + customWeights: z.string().transform(parseCustomWeights).optional(), }), }, usesDataSource: true, shouldCache: true, - createKey: async ({ query }) => { + createKey: async ({ query }, ctx) => { const metricsKey = { totalPoints: query.totalPoints || 0, autoPoints: query.autoPoints || 0, @@ -71,14 +100,34 @@ export const picklistShell = createAnalysisHandler({ estimatedTotalFuelScored: query.estimatedTotalFuelScored || 0, }; + const key = [ + "picklistShell", + query.tournamentKey || "", + JSON.stringify(query.flags || []), + JSON.stringify(metricsKey), + ]; + const teamDependencies: number[] = []; + + // Custom weights are viewer-scoped: fragment the key by weights + viewer + // team and depend on the viewer team ONLY when custom weights are present + // (zero key change otherwise), so config mutations invalidate the row. + const customWeights = query.customWeights ?? {}; + const sortedCustomKeys = Object.keys(customWeights).sort(); + if (sortedCustomKeys.length > 0) { + const sortedWeights: Record = {}; + for (const weightKey of sortedCustomKeys) { + sortedWeights[weightKey] = customWeights[weightKey]; + } + key.push(JSON.stringify(sortedWeights)); + key.push(`viewer${ctx.user.teamNumber ?? "none"}`); + if (ctx.user.teamNumber !== null && ctx.user.teamNumber !== undefined) { + teamDependencies.push(ctx.user.teamNumber); + } + } + return { - key: [ - "picklistShell", - query.tournamentKey || "", - JSON.stringify(query.flags || []), - JSON.stringify(metricsKey), - ], - teamDependencies: [], + key: key, + teamDependencies: teamDependencies, tournamentDependencies: query.tournamentKey ? [query.tournamentKey] : [], }; }, @@ -109,9 +158,30 @@ export const picklistShell = createAnalysisHandler({ estimatedTotalFuelScored: query.estimatedTotalFuelScored || 0, }; + // Validate custom weights: only the viewer's own active NUMBER fields + // count; stale/foreign/archived uuids are silently dropped. + const customWeights = query.customWeights ?? {}; + const validCustomWeights: Record = {}; + if ( + Object.keys(customWeights).length > 0 && + ctx.user.teamNumber !== null && + ctx.user.teamNumber !== undefined + ) { + const ownedFields = await getActiveCustomFields(ctx.user.teamNumber, [ + CustomFieldType.NUMBER, + ]); + const ownedUuids = new Set(ownedFields.map((field) => field.uuid)); + for (const [weightKey, weight] of Object.entries(customWeights)) { + const fieldUuid = parseCfKey(weightKey); + if (fieldUuid !== null && ownedUuids.has(fieldUuid)) { + validCustomWeights[fieldUuid] = weight; + } + } + } + //check for all metrics being 0, if so error const allMetricsZero = Object.values(metrics).every((v) => v === 0); - if (allMetricsZero) { + if (allMetricsZero && Object.keys(validCustomWeights).length === 0) { throw new Error("All weights are zero"); } @@ -163,6 +233,27 @@ export const picklistShell = createAnalysisHandler({ query.flags || [], ); + // Fold weighted custom NUMBER fields into breakdowns/totals BEFORE the + // final sort (viewer-scoped; cache key carries the viewer team fragment + // whenever custom weights are present) + if ( + Object.keys(validCustomWeights).length > 0 && + ctx.user.teamNumber !== null && + ctx.user.teamNumber !== undefined + ) { + const customAverages = await customFieldNumberManyFast(ctx.user, { + viewerTeam: ctx.user.teamNumber, + teams: includedTeams, + fieldUuids: Object.keys(validCustomWeights), + }); + applyCustomZScores( + dataArr, + includedTeams, + customAverages, + validCustomWeights, + ); + } + const resultArr = dataArr.sort((a, b) => b.result - a.result); return { teams: resultArr }; }, diff --git a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts index 6538379d..cf5fc388 100644 --- a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts +++ b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts @@ -14,6 +14,7 @@ import { import { autoPathScouter } from "./autoPathScouter.js"; import { averageScoutReport } from "../coreAnalysis/averageScoutReport.js"; import { createAnalysisHandler } from "../analysisHandler.js"; +import { getAnswersForReport } from "../customFields/customFieldShared.js"; export const matchPageSpecificScouter = createAnalysisHandler({ params: { @@ -48,6 +49,11 @@ export const matchPageSpecificScouter = createAnalysisHandler({ autoClimb: true, feederTypes: true, accuracy: true, + scouter: { + select: { + sourceTeamNumber: true, + }, + }, }, }); @@ -117,6 +123,16 @@ export const matchPageSpecificScouter = createAnalysisHandler({ output[metricToName[metric]] = aggregateData[metric]; } + // Custom field answers are only visible to viewers on the report's source + // team; everyone else gets an empty array (handler is shouldCache: false, + // so this viewer-dependent data is safe to compute inline). + output.customFieldAnswers = + ctx.user.teamNumber !== null && + ctx.user.teamNumber !== undefined && + scoutReport.scouter?.sourceTeamNumber === ctx.user.teamNumber + ? await getAnswersForReport(scoutReport.uuid) + : []; + return output; }, }); diff --git a/src/handler/analysis/teamLookUp/breakdownDetails.ts b/src/handler/analysis/teamLookUp/breakdownDetails.ts index 23143470..0932a331 100644 --- a/src/handler/analysis/teamLookUp/breakdownDetails.ts +++ b/src/handler/analysis/teamLookUp/breakdownDetails.ts @@ -1,7 +1,9 @@ import z from "zod"; +import { CustomFieldType } from "@prisma/client"; import { allTeamNumbers, allTournaments, + AnalysisContext, breakdownNeg, breakdownPos, dashboardToServer, @@ -12,6 +14,12 @@ import { dataSourceRuleToArray, } from "../dataSourceRule.js"; import prismaClient from "../../../prismaClient.js"; +import { + getTournamentSourceRule, + parseCfKey, + teamSourceRuleAllowsOwnTeam, + tournamentRuleToSqlCondition, +} from "../customFields/customFieldShared.js"; export const breakdownDetails = createAnalysisHandler({ params: { @@ -22,18 +30,39 @@ export const breakdownDetails = createAnalysisHandler({ }, usesDataSource: true, shouldCache: true, - createKey: async ({ params }) => { + createKey: async ({ params }, ctx) => { + const key = [ + "breakdownDetails", + params.team.toString(), + params.breakdown.toString(), + ]; + const teamDependencies = [params.team]; + + // cf_ breakdowns are viewer-scoped: fragment the key by viewer team (no + // key change at all for ordinary breakdowns) and depend on the viewer team + // so custom field config mutations invalidate the row. + if (parseCfKey(params.breakdown) !== null) { + key.push(`viewer${ctx.user.teamNumber ?? "none"}`); + if ( + ctx.user.teamNumber !== null && + ctx.user.teamNumber !== undefined && + !teamDependencies.includes(ctx.user.teamNumber) + ) { + teamDependencies.push(ctx.user.teamNumber); + } + } + return { - key: [ - "breakdownDetails", - params.team.toString(), - params.breakdown.toString(), - ], - teamDependencies: [params.team], + key: key, + teamDependencies: teamDependencies, tournamentDependencies: [], }; }, calculateAnalysis: async ({ params }, ctx) => { + const cfUuid = parseCfKey(params.breakdown); + if (cfUuid !== null) { + return customFieldBreakdownDetails(cfUuid, params.team, ctx); + } const queryStr = ` SELECT "${dashboardToServer[params.breakdown]}" AS breakdown, "teamMatchKey" AS key, @@ -138,3 +167,90 @@ export const breakdownDetails = createAnalysisHandler({ return result; }, }); + +/** + * cf_ branch of breakdownDetails: expands one row per stored selection of the + * viewer's custom select field for the scouted team. Viewer-scoped — only + * reports submitted by the viewer's own team count; the field must belong to + * the viewer and be a select type (archived allowed so old links keep + * working), otherwise empty. + */ +async function customFieldBreakdownDetails( + fieldUuid: string, + team: number, + ctx: AnalysisContext, +): Promise< + { + key: string; + tournamentName: string; + breakdown: string; + sourceTeam: string; + scouter?: string; + }[] +> { + const viewerTeam = ctx.user.teamNumber; + if (viewerTeam === null || viewerTeam === undefined) return []; + if (!teamSourceRuleAllowsOwnTeam(ctx.user)) return []; + + const field = await prismaClient.customField.findUnique({ + where: { uuid: fieldUuid }, + }); + if ( + !field || + field.teamNumber !== viewerTeam || + (field.type !== CustomFieldType.SINGLE_SELECT && + field.type !== CustomFieldType.MULTI_SELECT) + ) { + return []; + } + + const tournamentRule = getTournamentSourceRule(ctx.user); + const tournamentCondition = tournamentRuleToSqlCondition( + tournamentRule, + `tmd."tournamentKey"`, + 4, + ); + + const queryStr = ` + SELECT sel.value AS breakdown, + sr."teamMatchKey" AS key, + tmnt."name" AS tournament, + sc."sourceTeamNumber" AS sourceteam, + sc."name" AS scouter + FROM "CustomFieldAnswer" a + JOIN "ScoutReport" sr ON sr."uuid" = a."scoutReportUuid" + JOIN "Scouter" sc ON sc."uuid" = sr."scouterUuid" + JOIN "TeamMatchData" tmd ON tmd."key" = sr."teamMatchKey" + JOIN "Tournament" tmnt ON tmnt."key" = tmd."tournamentKey" + CROSS JOIN UNNEST(a."selections") AS sel(value) + WHERE a."fieldUuid" = $1 + AND sc."sourceTeamNumber" = $2 + AND tmd."teamNumber" = $3 + AND ${tournamentCondition.clause} + ORDER BY tmnt."date" DESC, tmd."matchType" DESC, tmd."matchNumber" DESC + `; + + interface QueryRow { + breakdown: string; + key: string; + tournament: string; + sourceteam: string; + scouter: string; + } + + const data = await prismaClient.$queryRawUnsafe( + queryStr, + fieldUuid, + viewerTeam, + team, + tournamentCondition.param, + ); + + return data.map((match) => ({ + key: match.key, + tournamentName: match.tournament, + breakdown: match.breakdown, + sourceTeam: match.sourceteam, + scouter: match.scouter ?? undefined, + })); +} diff --git a/src/handler/analysis/teamLookUp/breakdownMetrics.ts b/src/handler/analysis/teamLookUp/breakdownMetrics.ts index 145ab272..fcafb5d3 100644 --- a/src/handler/analysis/teamLookUp/breakdownMetrics.ts +++ b/src/handler/analysis/teamLookUp/breakdownMetrics.ts @@ -1,8 +1,14 @@ import z from "zod"; +import { CustomFieldType } from "@prisma/client"; import prismaClient from "../../../prismaClient.js"; import { nonEventMetric } from "../coreAnalysis/nonEventMetric.js"; import { MetricsBreakdown } from "../analysisConstants.js"; import { createAnalysisHandler } from "../analysisHandler.js"; +import { customFieldSelectDistribution } from "../customFields/customFieldSelectDistribution.js"; +import { + cfKey, + getActiveCustomFields, +} from "../customFields/customFieldShared.js"; export const breakdownMetrics = createAnalysisHandler({ params: { @@ -56,4 +62,61 @@ export const breakdownMetrics = createAnalysisHandler({ return result; }, + // Custom select-field distributions are viewer-scoped, so they ride on the + // response via this hook and are never written into the shared cache row. + // The distributions themselves are cached separately (viewer-keyed) by + // customFieldSelectDistribution. Only fields with at least one answer are + // included (no empty sections); with no team or no active select fields the + // response is byte-identical to today's. + augmentResponse: async ({ params }, ctx, result) => { + const viewerTeam = ctx.user.teamNumber; + if (viewerTeam === null || viewerTeam === undefined) return result; + + // Cheap pre-check so viewers without custom fields skip the cached + // distribution function entirely (one indexed lookup) + const activeSelectFields = await getActiveCustomFields(viewerTeam, [ + CustomFieldType.SINGLE_SELECT, + CustomFieldType.MULTI_SELECT, + ]); + if (activeSelectFields.length === 0) return result; + + const distribution = await customFieldSelectDistribution(ctx.user, { + viewerTeam: viewerTeam, + team: params.team, + }); + + const answeredFields = distribution.fields.filter( + (field) => field.answerCount > 0, + ); + if (answeredFields.length === 0) return result; + + // Never mutate result: on cache misses the raw object is what gets stored + const augmented: Record = { ...result }; + const customFields: { + uuid: string; + metricKey: string; + name: string; + order: number; + type: CustomFieldType; + options: string[]; + answerCount: number; + }[] = []; + + for (const field of answeredFields) { + const metricKey = cfKey(field.uuid); + augmented[metricKey] = field.percentages; + customFields.push({ + uuid: field.uuid, + metricKey: metricKey, + name: field.name, + order: field.order, + type: field.type, + options: field.options, + answerCount: field.answerCount, + }); + } + + augmented.customFields = customFields; + return augmented; + }, }); diff --git a/src/handler/analysis/teamLookUp/categoryMetrics.ts b/src/handler/analysis/teamLookUp/categoryMetrics.ts index 97e165ef..077de8f0 100644 --- a/src/handler/analysis/teamLookUp/categoryMetrics.ts +++ b/src/handler/analysis/teamLookUp/categoryMetrics.ts @@ -1,8 +1,14 @@ import z from "zod"; +import { CustomFieldType } from "@prisma/client"; import prismaClient from "../../../prismaClient.js"; import { metricsCategory, metricToName } from "../analysisConstants.js"; import { arrayAndAverageTeams } from "../coreAnalysis/arrayAndAverageTeams.js"; import { createAnalysisHandler } from "../analysisHandler.js"; +import { customFieldNumberManyFast } from "../customFields/customFieldNumberAverages.js"; +import { + cfKey, + getActiveCustomFields, +} from "../customFields/customFieldShared.js"; export const categoryMetrics = createAnalysisHandler({ params: { @@ -56,4 +62,53 @@ export const categoryMetrics = createAnalysisHandler({ return result; }, + // Custom NUMBER field averages are viewer-scoped, so they ride on the + // response via this hook and are never written into the shared cache row. + // The custom values themselves are cached separately (viewer-keyed) by + // customFieldNumberManyFast. When the viewer has no team or no active + // NUMBER fields, the response is byte-identical to today's. + augmentResponse: async ({ params }, ctx, result) => { + const viewerTeam = ctx.user.teamNumber; + if (viewerTeam === null || viewerTeam === undefined) return result; + + const fields = await getActiveCustomFields(viewerTeam, [ + CustomFieldType.NUMBER, + ]); + if (fields.length === 0) return result; + + const averages = await customFieldNumberManyFast(ctx.user, { + viewerTeam: viewerTeam, + teams: [params.team], + fieldUuids: fields.map((field) => field.uuid), + }); + + // Never mutate result: on cache misses the raw object is what gets stored + const augmented: Record = { ...result }; + const customFields: { + uuid: string; + metricKey: string; + name: string; + order: number; + average: number | null; + }[] = []; + + for (const field of fields) { + // Already rounded to 2dp by runAnalysis + const average = averages[field.uuid]?.[String(params.team)] ?? null; + const metricKey = cfKey(field.uuid); + if (average !== null) { + augmented[metricKey] = average; + } + customFields.push({ + uuid: field.uuid, + metricKey: metricKey, + name: field.name, + order: field.order, + average: average, + }); + } + + augmented.customFields = customFields; + return augmented; + }, }); diff --git a/src/handler/analysis/teamLookUp/detailsPage.ts b/src/handler/analysis/teamLookUp/detailsPage.ts index 0c5726f9..0bc484ae 100644 --- a/src/handler/analysis/teamLookUp/detailsPage.ts +++ b/src/handler/analysis/teamLookUp/detailsPage.ts @@ -1,9 +1,16 @@ import z from "zod"; +import { CustomFieldType } from "@prisma/client"; +import prismaClient from "../../../prismaClient.js"; import { autoPathsTeam } from "../autoPaths/autoPathsTeam.js"; import { averageAllTeamFast } from "../coreAnalysis/averageAllTeamFast.js"; import { Metric, metricsToNumber } from "../analysisConstants.js"; import { arrayAndAverageTeams } from "../coreAnalysis/arrayAndAverageTeams.js"; import { createAnalysisHandler } from "../analysisHandler.js"; +import { + customFieldNumberAll, + customFieldNumberTeams, +} from "../customFields/customFieldNumberAverages.js"; +import { parseCfKey } from "../customFields/customFieldShared.js"; export const detailsPage = createAnalysisHandler({ params: { @@ -15,14 +22,80 @@ export const detailsPage = createAnalysisHandler({ }, usesDataSource: true, shouldCache: true, - createKey: async ({ params }) => { + createKey: async ({ params }, ctx) => { + const key = ["detailsPage", params.team.toString(), params.metric.toString()]; + const teamDependencies = [params.team]; + + // cf_ metrics are viewer-scoped: fragment the key by viewer team (no key + // change at all for ordinary metrics) and depend on the viewer team so + // custom field config mutations invalidate the row. + if (parseCfKey(params.metric) !== null) { + key.push(`viewer${ctx.user.teamNumber ?? "none"}`); + if ( + ctx.user.teamNumber !== null && + ctx.user.teamNumber !== undefined && + !teamDependencies.includes(ctx.user.teamNumber) + ) { + teamDependencies.push(ctx.user.teamNumber); + } + } + return { - key: ["detailsPage", params.team.toString(), params.metric.toString()], - teamDependencies: [params.team], + key: key, + teamDependencies: teamDependencies, tournamentDependencies: [], }; }, calculateAnalysis: async ({ params }, ctx) => { + const cfUuid = parseCfKey(params.metric); + if (cfUuid !== null) { + // Ownership check: the field must belong to the viewer's team and be a + // NUMBER field. Archived fields still resolve so old links keep working. + const viewerTeam = ctx.user.teamNumber; + if (viewerTeam === null || viewerTeam === undefined) { + return { error: "METRIC_DOES_NOT_EXIST" }; + } + + const field = await prismaClient.customField.findUnique({ + where: { uuid: cfUuid }, + }); + if ( + !field || + field.teamNumber !== viewerTeam || + field.type !== CustomFieldType.NUMBER + ) { + return { error: "METRIC_DOES_NOT_EXIST" }; + } + + const teamData = ( + await customFieldNumberTeams(ctx.user, { + viewerTeam: viewerTeam, + teams: [params.team], + fieldUuid: cfUuid, + }) + )[String(params.team)]; + const allTeamAverage = await customFieldNumberAll(ctx.user, { + viewerTeam: viewerTeam, + fieldUuid: cfUuid, + }); + + // Match existing metrics: teams/fields with no data report 0 + const resultValue = teamData?.average ?? 0; + const allValue = allTeamAverage ?? 0; + return { + array: teamData?.timeLine ?? [], + result: resultValue, + all: allValue, + difference: resultValue - allValue, + team: params.team, + customField: { + uuid: field.uuid, + name: field.name, + archived: field.archived, + }, + }; + } + if (metricsToNumber[params.metric] === Metric.autoPoints) { const autoPaths = await autoPathsTeam(ctx.user, { team: params.team }); return { paths: autoPaths }; diff --git a/src/handler/manager/customfields/addCustomField.ts b/src/handler/manager/customfields/addCustomField.ts new file mode 100644 index 00000000..47b026c0 --- /dev/null +++ b/src/handler/manager/customfields/addCustomField.ts @@ -0,0 +1,118 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { CustomFieldType, UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +export const MAX_ACTIVE_CUSTOM_FIELDS = 25; + +const selectTypes: CustomFieldType[] = [ + CustomFieldType.SINGLE_SELECT, + CustomFieldType.MULTI_SELECT, +]; + +export const addCustomField = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + name: z.string().trim().min(1).max(100), + type: z.nativeEnum(CustomFieldType), + options: z.array(z.string().trim().min(1).max(80)).max(30).optional(), + }) + .safeParse(req.body); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: "You need to be a scouting lead to add custom fields", + }); + return; + } + + const options = params.data.options ?? []; + const isSelect = selectTypes.includes(params.data.type); + + if (isSelect) { + if (options.length === 0) { + res.status(400).send({ + error: `Custom field of type ${params.data.type} requires at least one option`, + displayError: "Select fields need at least one option", + }); + return; + } + if (new Set(options).size !== options.length) { + res.status(400).send({ + error: `Custom field options must be unique`, + displayError: "Options must be unique", + }); + return; + } + } else if (options.length > 0) { + res.status(400).send({ + error: `Custom field of type ${params.data.type} cannot have options`, + displayError: "Text and number fields can't have options", + }); + return; + } + + const activeCount = await prismaClient.customField.count({ + where: { + teamNumber: req.user.teamNumber, + archived: false, + }, + }); + if (activeCount >= MAX_ACTIVE_CUSTOM_FIELDS) { + res.status(400).send({ + error: `Team ${req.user.teamNumber} already has ${MAX_ACTIVE_CUSTOM_FIELDS} active custom fields`, + displayError: `Your team already has ${MAX_ACTIVE_CUSTOM_FIELDS} active custom fields. Archive one to add more.`, + }); + return; + } + + const maxOrder = await prismaClient.customField.aggregate({ + where: { + teamNumber: req.user.teamNumber, + }, + _max: { order: true }, + }); + + const field = await prismaClient.customField.create({ + data: { + teamNumber: req.user.teamNumber, + name: params.data.name, + type: params.data.type, + options: isSelect ? options : [], + order: (maxOrder._max.order ?? -1) + 1, + }, + }); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send(field); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/archiveCustomField.ts b/src/handler/manager/customfields/archiveCustomField.ts new file mode 100644 index 00000000..dbe4cdd7 --- /dev/null +++ b/src/handler/manager/customfields/archiveCustomField.ts @@ -0,0 +1,82 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +export const archiveCustomField = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + uuid: z.string(), + }) + .safeParse(req.params); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: + "You need to be a scouting lead to archive custom fields", + }); + return; + } + + const field = await prismaClient.customField.findUnique({ + where: { + uuid: params.data.uuid, + }, + }); + if (!field) { + res.status(404).send({ + error: `Custom field with the uuid ${params.data.uuid} does not exist`, + displayError: "Custom field not found", + }); + return; + } + if (field.teamNumber !== req.user.teamNumber) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not on the same team as the custom field ${field.uuid}`, + displayError: "Not authorized to archive this custom field", + }); + return; + } + + await prismaClient.customField.update({ + where: { + uuid: field.uuid, + }, + data: { + archived: true, + }, + }); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send("done archiving custom field"); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/deleteCustomField.ts b/src/handler/manager/customfields/deleteCustomField.ts new file mode 100644 index 00000000..2a6d1d32 --- /dev/null +++ b/src/handler/manager/customfields/deleteCustomField.ts @@ -0,0 +1,93 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +export const deleteCustomField = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + uuid: z.string(), + }) + .safeParse(req.params); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: + "You need to be a scouting lead to delete custom fields", + }); + return; + } + + const field = await prismaClient.customField.findUnique({ + where: { + uuid: params.data.uuid, + }, + include: { + _count: { + select: { answers: true }, + }, + }, + }); + if (!field) { + res.status(404).send({ + error: `Custom field with the uuid ${params.data.uuid} does not exist`, + displayError: "Custom field not found", + }); + return; + } + if (field.teamNumber !== req.user.teamNumber) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not on the same team as the custom field ${field.uuid}`, + displayError: "Not authorized to delete this custom field", + }); + return; + } + + if (field._count.answers > 0) { + res.status(409).send({ + error: `Custom field ${field.uuid} has ${field._count.answers} recorded answers`, + displayError: + "This field has recorded answers. Archive it instead to preserve them.", + }); + return; + } + + await prismaClient.customField.delete({ + where: { + uuid: field.uuid, + }, + }); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send("done deleting custom field"); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/getCustomFields.ts b/src/handler/manager/customfields/getCustomFields.ts new file mode 100644 index 00000000..f5c4515e --- /dev/null +++ b/src/handler/manager/customfields/getCustomFields.ts @@ -0,0 +1,49 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; + +export const getCustomFields = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + const params = z + .object({ + archived: z + .string() + .transform((val) => val === "true") + .optional(), + }) + .safeParse(req.query); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if (req.user.teamNumber === null) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not affiliated with a team`, + displayError: "User is not affiliated with a team", + }); + return; + } + + const rows = await prismaClient.customField.findMany({ + where: { + teamNumber: req.user.teamNumber, + archived: params.data.archived, + }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }, { uuid: "asc" }], + }); + + res.status(200).send(rows); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/getCustomFieldsManifest.ts b/src/handler/manager/customfields/getCustomFieldsManifest.ts new file mode 100644 index 00000000..480deabd --- /dev/null +++ b/src/handler/manager/customfields/getCustomFieldsManifest.ts @@ -0,0 +1,56 @@ +import { Request, Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import SHA256 from "crypto-js/sha256.js"; +import { getActiveCustomFields } from "../../analysis/customFields/customFieldShared.js"; + +export const getCustomFieldsManifest = async ( + req: Request, + res: Response, +): Promise => { + try { + const params = z + .object({ + teamCode: z.string(), + }) + .safeParse({ + teamCode: req.headers["x-team-code"], + }); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + const teamRow = await prismaClient.registeredTeam.findUnique({ + where: { + code: params.data.teamCode, + }, + }); + if (!teamRow) { + res.status(404).send({ + error: `The team code ${params.data.teamCode}`, + displayError: "Team code does not exist", + }); + return; + } + + const fields = await getActiveCustomFields(teamRow.number); + const data = fields.map((field) => ({ + uuid: field.uuid, + name: field.name, + type: field.type, + options: field.options, + })); + + res + .status(200) + .send({ hash: SHA256(JSON.stringify(data)).toString(), data: data }); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/reorderCustomFields.ts b/src/handler/manager/customfields/reorderCustomFields.ts new file mode 100644 index 00000000..705feb0c --- /dev/null +++ b/src/handler/manager/customfields/reorderCustomFields.ts @@ -0,0 +1,87 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +export const reorderCustomFields = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + fieldUuids: z.array(z.string()), + }) + .safeParse(req.body); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: + "You need to be a scouting lead to reorder custom fields", + }); + return; + } + + const activeFields = await prismaClient.customField.findMany({ + where: { + teamNumber: req.user.teamNumber, + archived: false, + }, + select: { uuid: true }, + }); + + // The submitted list must be an exact permutation of the team's active + // fields — anything else means the client is working from a stale list + const activeUuids = new Set(activeFields.map((field) => field.uuid)); + const submitted = params.data.fieldUuids; + const isExactPermutation = + submitted.length === activeUuids.size && + new Set(submitted).size === submitted.length && + submitted.every((uuid) => activeUuids.has(uuid)); + + if (!isExactPermutation) { + res.status(400).send({ + error: `Submitted field uuids are not a permutation of team ${req.user.teamNumber}'s active custom fields`, + displayError: "Field list is out of date. Refresh and try again.", + }); + return; + } + + await prismaClient.$transaction( + submitted.map((uuid, index) => + prismaClient.customField.update({ + where: { uuid: uuid }, + data: { order: index }, + }), + ), + ); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send("done reordering custom fields"); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/unarchiveCustomField.ts b/src/handler/manager/customfields/unarchiveCustomField.ts new file mode 100644 index 00000000..c41f96d3 --- /dev/null +++ b/src/handler/manager/customfields/unarchiveCustomField.ts @@ -0,0 +1,102 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; +import { MAX_ACTIVE_CUSTOM_FIELDS } from "./addCustomField.js"; + +export const unarchiveCustomField = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + uuid: z.string(), + }) + .safeParse(req.params); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: + "You need to be a scouting lead to unarchive custom fields", + }); + return; + } + + const field = await prismaClient.customField.findUnique({ + where: { + uuid: params.data.uuid, + }, + }); + if (!field) { + res.status(404).send({ + error: `Custom field with the uuid ${params.data.uuid} does not exist`, + displayError: "Custom field not found", + }); + return; + } + if (field.teamNumber !== req.user.teamNumber) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not on the same team as the custom field ${field.uuid}`, + displayError: "Not authorized to unarchive this custom field", + }); + return; + } + + if (!field.archived) { + res.status(200).send("done unarchiving custom field"); + return; + } + + const activeCount = await prismaClient.customField.count({ + where: { + teamNumber: req.user.teamNumber, + archived: false, + }, + }); + if (activeCount >= MAX_ACTIVE_CUSTOM_FIELDS) { + res.status(400).send({ + error: `Team ${req.user.teamNumber} already has ${MAX_ACTIVE_CUSTOM_FIELDS} active custom fields`, + displayError: `Your team already has ${MAX_ACTIVE_CUSTOM_FIELDS} active custom fields. Archive one before unarchiving this field.`, + }); + return; + } + + await prismaClient.customField.update({ + where: { + uuid: field.uuid, + }, + data: { + archived: false, + }, + }); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send("done unarchiving custom field"); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/updateCustomField.ts b/src/handler/manager/customfields/updateCustomField.ts new file mode 100644 index 00000000..c92ad353 --- /dev/null +++ b/src/handler/manager/customfields/updateCustomField.ts @@ -0,0 +1,122 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { CustomFieldType, UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +export const updateCustomField = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + uuid: z.string(), + name: z.string().trim().min(1).max(100).optional(), + options: z.array(z.string().trim().min(1).max(80)).max(30).optional(), + }) + .safeParse({ + uuid: req.params.uuid, + name: req.body.name, + options: req.body.options, + }); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: "You need to be a scouting lead to edit custom fields", + }); + return; + } + + const field = await prismaClient.customField.findUnique({ + where: { + uuid: params.data.uuid, + }, + }); + if (!field) { + res.status(404).send({ + error: `Custom field with the uuid ${params.data.uuid} does not exist`, + displayError: "Custom field not found", + }); + return; + } + if (field.teamNumber !== req.user.teamNumber) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not on the same team as the custom field ${field.uuid}`, + displayError: "Not authorized to edit this custom field", + }); + return; + } + + const options = params.data.options; + if (options !== undefined) { + if ( + field.type === CustomFieldType.TEXT || + field.type === CustomFieldType.NUMBER + ) { + if (options.length > 0) { + res.status(400).send({ + error: `Custom field of type ${field.type} cannot have options`, + displayError: "Text and number fields can't have options", + }); + return; + } + } else { + if (new Set(options).size !== options.length) { + res.status(400).send({ + error: `Custom field options must be unique`, + displayError: "Options must be unique", + }); + return; + } + // Options are append/reorder-only so stored answers stay valid + const newOptionSet = new Set(options); + if (field.options.some((option) => !newOptionSet.has(option))) { + res.status(400).send({ + error: `Options for custom field ${field.uuid} can only be added or reordered`, + displayError: + "Options can only be added or reordered. Existing options can't be removed or renamed.", + }); + return; + } + } + } + + const updated = await prismaClient.customField.update({ + where: { + uuid: field.uuid, + }, + data: { + ...(params.data.name !== undefined ? { name: params.data.name } : {}), + ...(options !== undefined ? { options: options } : {}), + }, + }); + + await invalidateCache(req.user.teamNumber, []); + + res.status(200).send(updated); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/handler/manager/customfields/validateCustomFieldAnswers.ts b/src/handler/manager/customfields/validateCustomFieldAnswers.ts new file mode 100644 index 00000000..8efe55cd --- /dev/null +++ b/src/handler/manager/customfields/validateCustomFieldAnswers.ts @@ -0,0 +1,118 @@ +import z from "zod"; +import prismaClient from "../../../prismaClient.js"; +import { CustomFieldType } from "@prisma/client"; + +// Wire shape for custom field answers on scout report submission +// (reconciliation decision 1): one `value` key for all types — +// TEXT/SINGLE_SELECT -> string, NUMBER -> number, MULTI_SELECT -> string[] +export const CustomFieldAnswersInputSchema = z + .array( + z.object({ + fieldUuid: z.string(), + value: z.union([ + z.string().max(1000), + z.number(), + z.array(z.string().max(80)).max(30), + ]), + }), + ) + .max(50); + +export type CustomFieldAnswersInput = z.infer< + typeof CustomFieldAnswersInputSchema +>; + +export type ValidatedCustomFieldAnswerRow = { + fieldUuid: string; + textValue: string | null; + numberValue: number | null; + selections: string[]; +}; + +/** + * Lenient per-answer validation (reconciliation decision 4): a bad custom + * answer must never reject a whole scout report. Unknown/wrong-team uuids, + * type mismatches, out-of-options selections, duplicate fieldUuids (first + * occurrence wins), blank text, and empty multi-selects are silently dropped. + * Archived fields are accepted and stored. + */ +export const validateCustomFieldAnswers = async ( + sourceTeamNumber: number, + answers: CustomFieldAnswersInput | undefined, +): Promise => { + if (!answers || answers.length === 0) { + return []; + } + + const fields = await prismaClient.customField.findMany({ + where: { + uuid: { in: [...new Set(answers.map((answer) => answer.fieldUuid))] }, + teamNumber: sourceTeamNumber, + }, + }); + const fieldsByUuid = new Map(fields.map((field) => [field.uuid, field])); + + const rows: ValidatedCustomFieldAnswerRow[] = []; + const seenFieldUuids = new Set(); + + for (const answer of answers) { + if (seenFieldUuids.has(answer.fieldUuid)) continue; + seenFieldUuids.add(answer.fieldUuid); + + const field = fieldsByUuid.get(answer.fieldUuid); + if (!field) continue; + + switch (field.type) { + case CustomFieldType.TEXT: { + if (typeof answer.value !== "string") break; + const trimmed = answer.value.trim(); + if (trimmed.length === 0) break; + rows.push({ + fieldUuid: field.uuid, + textValue: trimmed, + numberValue: null, + selections: [], + }); + break; + } + case CustomFieldType.NUMBER: { + if (typeof answer.value !== "number" || !Number.isFinite(answer.value)) + break; + rows.push({ + fieldUuid: field.uuid, + textValue: null, + numberValue: answer.value, + selections: [], + }); + break; + } + case CustomFieldType.SINGLE_SELECT: { + if (typeof answer.value !== "string") break; + if (!field.options.includes(answer.value)) break; + rows.push({ + fieldUuid: field.uuid, + textValue: null, + numberValue: null, + selections: [answer.value], + }); + break; + } + case CustomFieldType.MULTI_SELECT: { + if (!Array.isArray(answer.value)) break; + const selections = [...new Set(answer.value)].filter((option) => + field.options.includes(option), + ); + if (selections.length === 0) break; + rows.push({ + fieldUuid: field.uuid, + textValue: null, + numberValue: null, + selections: selections, + }); + break; + } + } + } + + return rows; +}; diff --git a/src/handler/manager/picklists/addPicklist.ts b/src/handler/manager/picklists/addPicklist.ts index 796af297..63e161e2 100644 --- a/src/handler/manager/picklists/addPicklist.ts +++ b/src/handler/manager/picklists/addPicklist.ts @@ -35,6 +35,9 @@ export const addPicklist = async ( estimatedSuccessfulFuelRate: z.number(), estimatedTotalFuelScored: z.number(), driverAbility: z.number(), + customFieldWeights: z + .record(z.string().startsWith("cf_"), z.number()) + .optional(), }) .safeParse({ authorId: req.user.id, @@ -55,6 +58,7 @@ export const addPicklist = async ( scoringRate: req.body.scoringRate || 0, estimatedSuccessfulFuelRate: req.body.estimatedSuccessfulFuelRate || 0, estimatedTotalFuelScored: req.body.estimatedTotalFuelScored || 0, + customFieldWeights: req.body.customFieldWeights, }); if (!params.success) { @@ -89,6 +93,7 @@ export const addPicklist = async ( scoringRate: params.data.scoringRate, estimatedSuccessfulFuelRate: params.data.estimatedSuccessfulFuelRate, estimatedTotalFuelScored: params.data.estimatedTotalFuelScored, + customFieldWeights: params.data.customFieldWeights ?? {}, }, }); res.status(200).send("picklist added"); diff --git a/src/handler/manager/picklists/getSinglePicklist.ts b/src/handler/manager/picklists/getSinglePicklist.ts index 2ebf792b..c3aee630 100644 --- a/src/handler/manager/picklists/getSinglePicklist.ts +++ b/src/handler/manager/picklists/getSinglePicklist.ts @@ -51,6 +51,7 @@ export const getSinglePicklist = async ( scoringRate: row.scoringRate, estimatedSuccessfulFuelRate: row.estimatedSuccessfulFuelRate, estimatedTotalFuelScored: row.estimatedTotalFuelScored, + customFieldWeights: row.customFieldWeights ?? {}, }; res.status(200).send(out); diff --git a/src/handler/manager/picklists/updatePicklist.ts b/src/handler/manager/picklists/updatePicklist.ts index 01419e69..6bf250c6 100644 --- a/src/handler/manager/picklists/updatePicklist.ts +++ b/src/handler/manager/picklists/updatePicklist.ts @@ -36,6 +36,9 @@ export const updatePicklist = async ( estimatedSuccessfulFuelRate: z.number(), estimatedTotalFuelScored: z.number(), authorId: z.string(), + customFieldWeights: z + .record(z.string().startsWith("cf_"), z.number()) + .optional(), }) .safeParse({ name: req.body.name, @@ -56,6 +59,7 @@ export const updatePicklist = async ( estimatedSuccessfulFuelRate: req.body.estimatedSuccessfulFuelRate || 0, estimatedTotalFuelScored: req.body.estimatedTotalFuelScored || 0, authorId: user.id, + customFieldWeights: req.body.customFieldWeights, }); if (!params.success) { @@ -88,6 +92,9 @@ export const updatePicklist = async ( estimatedSuccessfulFuelRate: params.data.estimatedSuccessfulFuelRate, estimatedTotalFuelScored: params.data.estimatedTotalFuelScored, authorId: params.data.authorId, + ...(params.data.customFieldWeights !== undefined + ? { customFieldWeights: params.data.customFieldWeights } + : {}), }, }); if (!row) { diff --git a/src/handler/manager/scoutreports/addScoutReport.ts b/src/handler/manager/scoutreports/addScoutReport.ts index cd4ece4e..d2c34ce1 100644 --- a/src/handler/manager/scoutreports/addScoutReport.ts +++ b/src/handler/manager/scoutreports/addScoutReport.ts @@ -20,6 +20,10 @@ import { } from "@prisma/client"; import { sendWarningToSlack } from "../../slack/sendWarningNotification.js"; import { invalidateCache } from "../../../lib/clearCache.js"; +import { + CustomFieldAnswersInputSchema, + validateCustomFieldAnswers, +} from "../customfields/validateCustomFieldAnswers.js"; const { PrismaClientKnownRequestError } = Prisma; @@ -127,11 +131,12 @@ export const addScoutReport = async ( scouterUuid: z.string(), teamNumber: z.number(), appVersion: z.string().optional(), + customFieldAnswers: CustomFieldAnswersInputSchema.optional(), }) .parse(req.body); // Check that scouter exists - await prismaClient.scouter.findFirstOrThrow({ + const scouter = await prismaClient.scouter.findFirstOrThrow({ where: { uuid: paramsScoutReport.scouterUuid, }, @@ -192,6 +197,13 @@ export const addScoutReport = async ( const matchKey = matchRow.key; + // Lenient per-answer validation: invalid custom answers are dropped, + // never fail the report (reconciliation decision 4) + const customFieldAnswerRows = await validateCustomFieldAnswers( + scouter.sourceTeamNumber, + paramsScoutReport.customFieldAnswers, + ); + // Create scout report in database await prismaClient.scoutReport.create({ data: { @@ -221,6 +233,16 @@ export const addScoutReport = async ( }, }); + if (customFieldAnswerRows.length > 0) { + await prismaClient.customFieldAnswer.createMany({ + data: customFieldAnswerRows.map((row) => ({ + ...row, + scoutReportUuid: paramsScoutReport.uuid, + })), + skipDuplicates: true, + }); + } + // Collect all affected cached analyses invalidateCache( paramsScoutReport.teamNumber, diff --git a/src/handler/manager/scoutreports/addScoutReportDashboard.ts b/src/handler/manager/scoutreports/addScoutReportDashboard.ts index c4601870..909022ea 100644 --- a/src/handler/manager/scoutreports/addScoutReportDashboard.ts +++ b/src/handler/manager/scoutreports/addScoutReportDashboard.ts @@ -26,6 +26,10 @@ import { checkForInvalidEvents, removeOrphanedStartEvents, } from "./addScoutReport.js"; +import { + CustomFieldAnswersInputSchema, + validateCustomFieldAnswers, +} from "../customfields/validateCustomFieldAnswers.js"; const { PrismaClientKnownRequestError } = Prisma; @@ -69,6 +73,7 @@ export const addScoutReportDashboard = async ( scouterUuid: z.string(), teamNumber: z.number(), appVersion: z.string().optional(), + customFieldAnswers: CustomFieldAnswersInputSchema.optional(), }) .parse(req.body); @@ -144,6 +149,13 @@ export const addScoutReportDashboard = async ( return; } + // Lenient per-answer validation: invalid custom answers are dropped, + // never fail the report (reconciliation decision 4) + const customFieldAnswerRows = await validateCustomFieldAnswers( + scouter.sourceTeamNumber, + paramsScoutReport.customFieldAnswers, + ); + // Create scout report using relations to match core handler await prismaClient.scoutReport.create({ data: { @@ -170,6 +182,16 @@ export const addScoutReportDashboard = async ( }, }); + if (customFieldAnswerRows.length > 0) { + await prismaClient.customFieldAnswer.createMany({ + data: customFieldAnswerRows.map((row) => ({ + ...row, + scoutReportUuid: paramsScoutReport.uuid, + })), + skipDuplicates: true, + }); + } + // Invalidate cached analyses invalidateCache( paramsScoutReport.teamNumber, diff --git a/src/handler/manager/scoutreports/getScoutReport.ts b/src/handler/manager/scoutreports/getScoutReport.ts index a4f77f0c..a3ee96ca 100644 --- a/src/handler/manager/scoutreports/getScoutReport.ts +++ b/src/handler/manager/scoutreports/getScoutReport.ts @@ -3,6 +3,7 @@ import prismaClient from "../../../prismaClient.js"; import z from "zod"; import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; import { UserRole } from "@prisma/client"; +import { getAnswersForReport } from "../../analysis/customFields/customFieldShared.js"; export const getScoutReport = async ( req: AuthenticatedRequest, @@ -53,15 +54,24 @@ export const getScoutReport = async ( const canModify = isOnSameTeam && user.role === UserRole.SCOUTING_LEAD; + // Custom field answers are scoped to the source team; cross-team viewers + // always get an empty array (key always present) + const customFieldAnswers = isOnSameTeam + ? await getAnswersForReport(params.data.uuid) + : []; + const { scouter, ...reportWithoutScouter } = scoutReport; const responseReport = { ...reportWithoutScouter, scouterName: isOnSameTeam ? scouter.name : undefined, }; - res - .status(200) - .send({ scoutReport: responseReport, events: events, canModify }); + res.status(200).send({ + scoutReport: responseReport, + events: events, + canModify, + customFieldAnswers, + }); } catch (error) { console.error(error); res.status(500).send(error); diff --git a/src/lib/prisma-zod.ts b/src/lib/prisma-zod.ts index 47d6a072..c78ff603 100644 --- a/src/lib/prisma-zod.ts +++ b/src/lib/prisma-zod.ts @@ -66,6 +66,12 @@ export const RobotRoleSchema = z.enum([ export const WarningTypeSchema = z.enum(["BREAK"]); export const UserRoleSchema = z.enum(["ANALYST", "SCOUTING_LEAD"]); export const MatchTypeSchema = z.enum(["QUALIFICATION", "ELIMINATION"]); +export const CustomFieldTypeSchema = z.enum([ + "TEXT", + "NUMBER", + "SINGLE_SELECT", + "MULTI_SELECT", +]); // Common JSON rule shapes used in User export const DataSourceRuleNumberSchema = z.object({ @@ -133,6 +139,26 @@ export const ScoutReportSchema = z.object({ autoClimb: AutoClimbSchema, }); +export const CustomFieldSchema = z.object({ + uuid: z.string().uuid(), + teamNumber: z.number().int(), + name: z.string(), + type: CustomFieldTypeSchema, + options: z.array(z.string()).default([]), + order: z.number().int(), + archived: z.boolean(), + createdAt: z.string().datetime().optional(), +}); + +export const CustomFieldAnswerSchema = z.object({ + uuid: z.string().uuid(), + scoutReportUuid: z.string().uuid(), + fieldUuid: z.string().uuid(), + textValue: z.string().optional().nullable(), + numberValue: z.number().optional().nullable(), + selections: z.array(z.string()).default([]), +}); + export const ScouterScheduleShiftSchema = z.object({ uuid: z.string().uuid(), sourceTeamNumber: z.number().int(), @@ -281,12 +307,15 @@ export function registerPrismaSchemas(registry: OpenAPIRegistry) { registry.register("WarningType", WarningTypeSchema); registry.register("UserRole", UserRoleSchema); registry.register("MatchType", MatchTypeSchema); + registry.register("CustomFieldType", CustomFieldTypeSchema); registry.register("Event", EventSchema); registry.register("FeatureToggle", FeatureToggleSchema); registry.register("TeamMatchData", TeamMatchDataSchema); registry.register("MutablePicklist", MutablePicklistSchema); registry.register("ScoutReport", ScoutReportSchema); + registry.register("CustomField", CustomFieldSchema); + registry.register("CustomFieldAnswer", CustomFieldAnswerSchema); registry.register("ScouterScheduleShift", ScouterScheduleShiftSchema); registry.register("Scouter", ScouterSchema); registry.register("SharedPicklist", SharedPicklistSchema); diff --git a/src/routes/analysis/analysis.routes.ts b/src/routes/analysis/analysis.routes.ts index 14cf3993..0b07f177 100644 --- a/src/routes/analysis/analysis.routes.ts +++ b/src/routes/analysis/analysis.routes.ts @@ -194,6 +194,12 @@ registry.registerPath({ scoringRate: z.coerce.number().optional(), estimatedSuccessfulFuelRate: z.coerce.number().optional(), estimatedTotalFuelScored: z.coerce.number().optional(), + customWeights: z + .string() + .optional() + .describe( + 'JSON object string mapping custom field metric keys to numeric weights, e.g. {"cf_": 0.5}. Keys must use the cf_ convention and reference the requesting team\'s active NUMBER custom fields; other entries and non-finite or zero weights are ignored.', + ), }), }, responses: { diff --git a/src/routes/manager/customfields.routes.ts b/src/routes/manager/customfields.routes.ts new file mode 100644 index 00000000..f99ad01c --- /dev/null +++ b/src/routes/manager/customfields.routes.ts @@ -0,0 +1,237 @@ +import { Router } from "express"; +import { requireAuth } from "../../lib/middleware/requireAuth.js"; +import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; +import { getCustomFieldsManifest } from "../../handler/manager/customfields/getCustomFieldsManifest.js"; +import { getCustomFields } from "../../handler/manager/customfields/getCustomFields.js"; +import { addCustomField } from "../../handler/manager/customfields/addCustomField.js"; +import { updateCustomField } from "../../handler/manager/customfields/updateCustomField.js"; +import { reorderCustomFields } from "../../handler/manager/customfields/reorderCustomFields.js"; +import { archiveCustomField } from "../../handler/manager/customfields/archiveCustomField.js"; +import { unarchiveCustomField } from "../../handler/manager/customfields/unarchiveCustomField.js"; +import { deleteCustomField } from "../../handler/manager/customfields/deleteCustomField.js"; + +import { registry } from "../../lib/openapi.js"; +import { z } from "zod"; +import { + CustomFieldSchema, + CustomFieldTypeSchema, +} from "../../lib/prisma-zod.js"; + +const CustomFieldManifestSchema = z.object({ + hash: z.string(), + data: z.array( + z.object({ + uuid: z.string(), + name: z.string(), + type: CustomFieldTypeSchema, + options: z.array(z.string()), + }), + ), +}); + +const CustomFieldCreateSchema = z.object({ + name: z.string().min(1).max(100), + type: CustomFieldTypeSchema, + options: z.array(z.string().min(1).max(80)).max(30).optional(), +}); + +const CustomFieldUpdateSchema = z.object({ + name: z.string().min(1).max(100).optional(), + options: z.array(z.string().min(1).max(80)).max(30).optional(), +}); + +const CustomFieldReorderSchema = z.object({ + fieldUuids: z.array(z.string()), +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/customfields/manifest", + tags: ["Manager - Custom Fields (Public)"], + summary: "List active custom fields for team code (collection manifest)", + request: { headers: z.object({ "x-team-code": z.string() }) }, + responses: { + 200: { + description: "Hash and active custom fields in display order", + content: { + "application/json": { schema: CustomFieldManifestSchema }, + }, + }, + 400: { description: "Invalid request" }, + 404: { description: "Team code not found" }, + 500: { description: "Server error" }, + }, +}); + +registry.registerPath({ + method: "get", + path: "/v1/manager/customfields", + tags: ["Manager - Custom Fields"], + summary: "List custom fields for current team (incl. archived)", + request: { query: z.object({ archived: z.string().optional() }) }, + responses: { + 200: { + description: "Custom fields in display order", + content: { + "application/json": { schema: z.array(CustomFieldSchema) }, + }, + }, + 400: { description: "Invalid request" }, + 401: { description: "Unauthorized" }, + 403: { description: "User not affiliated with a team" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "post", + path: "/v1/manager/customfields", + tags: ["Manager - Custom Fields"], + summary: "Create custom field (SCOUTING_LEAD)", + request: { + body: { + content: { "application/json": { schema: CustomFieldCreateSchema } }, + }, + }, + responses: { + 200: { + description: "Created", + content: { "application/json": { schema: CustomFieldSchema } }, + }, + 400: { description: "Invalid request or active field cap reached" }, + 401: { description: "Unauthorized" }, + 403: { description: "Not a scouting lead" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "put", + path: "/v1/manager/customfields/order", + tags: ["Manager - Custom Fields"], + summary: "Reorder active custom fields (SCOUTING_LEAD)", + request: { + body: { + content: { "application/json": { schema: CustomFieldReorderSchema } }, + }, + }, + responses: { + 200: { + description: "Reordered", + content: { "text/plain": { schema: z.string() } }, + }, + 400: { description: "Field list is out of date" }, + 401: { description: "Unauthorized" }, + 403: { description: "Not a scouting lead" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "put", + path: "/v1/manager/customfields/{uuid}", + tags: ["Manager - Custom Fields"], + summary: "Update custom field (SCOUTING_LEAD; type immutable)", + request: { + params: z.object({ uuid: z.string() }), + body: { + content: { "application/json": { schema: CustomFieldUpdateSchema } }, + }, + }, + responses: { + 200: { + description: "Updated", + content: { "application/json": { schema: CustomFieldSchema } }, + }, + 400: { description: "Invalid request or options removed/renamed" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "post", + path: "/v1/manager/customfields/{uuid}/archive", + tags: ["Manager - Custom Fields"], + summary: "Archive custom field (SCOUTING_LEAD)", + request: { params: z.object({ uuid: z.string() }) }, + responses: { + 200: { + description: "Archived", + content: { "text/plain": { schema: z.string() } }, + }, + 400: { description: "Invalid request" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "post", + path: "/v1/manager/customfields/{uuid}/unarchive", + tags: ["Manager - Custom Fields"], + summary: "Unarchive custom field (SCOUTING_LEAD)", + request: { params: z.object({ uuid: z.string() }) }, + responses: { + 200: { + description: "Unarchived", + content: { "text/plain": { schema: z.string() } }, + }, + 400: { description: "Active field cap reached" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +registry.registerPath({ + method: "delete", + path: "/v1/manager/customfields/{uuid}", + tags: ["Manager - Custom Fields"], + summary: "Delete custom field without answers (SCOUTING_LEAD)", + request: { params: z.object({ uuid: z.string() }) }, + responses: { + 200: { + description: "Deleted", + content: { "text/plain": { schema: z.string() } }, + }, + 400: { description: "Invalid request" }, + 401: { description: "Unauthorized" }, + 403: { description: "Forbidden" }, + 404: { description: "Not found" }, + 409: { description: "Field has recorded answers" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + +const router = Router(); + +// Public/unauthenticated endpoints (collection app, x-team-code header) +router.get("/manifest", getCustomFieldsManifest); + +router.use(requireAuth, requireVerifiedTeam); + +router.get("/", getCustomFields); +router.post("/", addCustomField); + +// Must be registered before the /:uuid routes +router.put("/order", reorderCustomFields); + +router.put("/:uuid", updateCustomField); +router.post("/:uuid/archive", archiveCustomField); +router.post("/:uuid/unarchive", unarchiveCustomField); +router.delete("/:uuid", deleteCustomField); + +export default router; diff --git a/src/routes/manager/manager.routes.ts b/src/routes/manager/manager.routes.ts index 333be0c5..547be5f6 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 customfields from "./customfields.routes.js"; import { getTournaments } from "../../handler/manager/getTournaments.js"; import { getTeams } from "../../handler/manager/getTeams.js"; @@ -36,6 +37,7 @@ import { } from "../../lib/prisma-zod.js"; import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; import { checkMatchExists } from "../../handler/manager/checkMatchExists.js"; +import { CustomFieldAnswersInputSchema } from "../../handler/manager/customfields/validateCustomFieldAnswers.js"; const router = Router(); @@ -303,6 +305,7 @@ registry.registerPath({ match: z.number().int(), team: z.number().int(), notes: z.string().optional(), + customFieldAnswers: CustomFieldAnswersInputSchema.optional(), }), }, }, @@ -452,6 +455,7 @@ router.use("/tournament", tournaments); router.use("/scoutreports", scoutreports); router.use("/settings", settings); router.use("/apikey", apikey); +router.use("/customfields", customfields); router.get("/teams", requireAuth, getTeams); router.get("/tournaments", requireAuth, getTournaments); diff --git a/src/routes/manager/picklists.routes.ts b/src/routes/manager/picklists.routes.ts index 88c62f36..f1379616 100644 --- a/src/routes/manager/picklists.routes.ts +++ b/src/routes/manager/picklists.routes.ts @@ -40,6 +40,9 @@ const PicklistCreateBodySchema = z.object({ estimatedSuccessfulFuelRate: z.number().default(0).optional(), estimatedTotalFuelScored: z.number().default(0).optional(), driverAbility: z.number().default(0).optional(), + customFieldWeights: z + .record(z.string().startsWith("cf_"), z.number()) + .optional(), }); const PicklistSummarySchema = z.object({ @@ -67,6 +70,7 @@ const PicklistDetailSchema = z.object({ scoringRate: z.number(), estimatedSuccessfulFuelRate: z.number(), estimatedTotalFuelScored: z.number(), + customFieldWeights: z.record(z.string().startsWith("cf_"), z.number()), }); const PicklistUpdateBodySchema = PicklistCreateBodySchema; diff --git a/src/routes/manager/scoutreports.routes.ts b/src/routes/manager/scoutreports.routes.ts index a8062d3c..e8a79b96 100644 --- a/src/routes/manager/scoutreports.routes.ts +++ b/src/routes/manager/scoutreports.routes.ts @@ -8,10 +8,24 @@ import { registry } from "../../lib/openapi.js"; import { z } from "zod"; import { + CustomFieldTypeSchema, EventSchema, ScoutReportSchema as PrismaScoutReportSchema, } from "../../lib/prisma-zod.js"; import { requireVerifiedTeam } from "../../lib/middleware/requireVerifiedTeam.js"; +import { CustomFieldAnswersInputSchema } from "../../handler/manager/customfields/validateCustomFieldAnswers.js"; + +const CustomFieldAnswerViewSchema = z.object({ + fieldUuid: z.string(), + name: z.string(), + type: CustomFieldTypeSchema, + options: z.array(z.string()), + order: z.number().int(), + archived: z.boolean(), + textValue: z.string().nullable(), + numberValue: z.number().nullable(), + selections: z.array(z.string()), +}); const ScoutReportCreateSchema = z.object({ uuid: z.string(), @@ -47,6 +61,7 @@ const ScoutReportCreateSchema = z.object({ z.number().int().optional(), // points/quantity (optional) ]), ), + customFieldAnswers: CustomFieldAnswersInputSchema.optional(), }); registry.registerPath({ @@ -85,6 +100,7 @@ registry.registerPath({ schema: z.object({ scoutReport: PrismaScoutReportSchema, events: z.array(EventSchema), + customFieldAnswers: z.array(CustomFieldAnswerViewSchema), }), }, }, From e3f9e0d62a0d5695d6f034e98cdcd2b6905fae4e Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Sun, 2 Aug 2026 21:49:55 -0700 Subject: [PATCH 2/7] Show text custom field answers on Notes tab and raw reports getNotes returns each report's Text custom answers (customTextAnswers), scoped by the same data-source rule as notes so it stays cache-safe, and now includes reports that have only a text answer. Both raw scout report endpoints return custom answers to any viewer who can see the report (not just the source team), since they display inline with their question names. Aggregate surfaces (categories/breakdowns/picklists) stay own-team-scoped. Co-Authored-By: Claude --- .../matchPageSpecificScouter.ts | 15 ++--- src/handler/analysis/teamLookUp/getNotes.ts | 62 ++++++++++++++++++- .../manager/scoutreports/getScoutReport.ts | 9 ++- 3 files changed, 69 insertions(+), 17 deletions(-) diff --git a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts index cf5fc388..b5bbbf78 100644 --- a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts +++ b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts @@ -123,15 +123,12 @@ export const matchPageSpecificScouter = createAnalysisHandler({ output[metricToName[metric]] = aggregateData[metric]; } - // Custom field answers are only visible to viewers on the report's source - // team; everyone else gets an empty array (handler is shouldCache: false, - // so this viewer-dependent data is safe to compute inline). - output.customFieldAnswers = - ctx.user.teamNumber !== null && - ctx.user.teamNumber !== undefined && - scoutReport.scouter?.sourceTeamNumber === ctx.user.teamNumber - ? await getAnswersForReport(scoutReport.uuid) - : []; + // Custom field answers are shown inline with their question names, so they + // read correctly for any viewer who can already see this report — not just + // the report's own team. (Aggregate surfaces like categories/breakdowns + // stay own-team-scoped because mixing teams' fields there is meaningless.) + // Handler is shouldCache: false, so computing per-request is fine. + output.customFieldAnswers = await getAnswersForReport(scoutReport.uuid); return output; }, diff --git a/src/handler/analysis/teamLookUp/getNotes.ts b/src/handler/analysis/teamLookUp/getNotes.ts index d7ca9b56..6d50e50a 100644 --- a/src/handler/analysis/teamLookUp/getNotes.ts +++ b/src/handler/analysis/teamLookUp/getNotes.ts @@ -1,5 +1,6 @@ import prismaClient from "../../../prismaClient.js"; import z from "zod"; +import { CustomFieldType } from "@prisma/client"; import { dataSourceRuleSchema, dataSourceRuleToPrismaFilter, @@ -49,6 +50,11 @@ export const getNotes = createAnalysisHandler({ tournamentName: string; sourceTeam: number; scouterName?: string; + // Free-form ("Text") custom field answers for this report, in canonical + // field order. Displayed inline with the note, each labeled with its + // question, so they follow the same data-source scoping as notes rather + // than the stricter own-team scoping used by aggregate custom surfaces. + customTextAnswers: { name: string; value: string }[]; }[]; const sourceTnmtFilter = dataSourceRuleToPrismaFilter( @@ -67,9 +73,21 @@ export const getNotes = createAnalysisHandler({ scouter: { sourceTeamNumber: sourceTeamFilter, }, - notes: { - not: "", - }, + // Include a report if it has a written note OR at least one answered + // text custom field, so text-only reports still get a card. + OR: [ + { notes: { not: "" } }, + { + customFieldAnswers: { + some: { + // Archived fields included: their answers are historical free + // text worth keeping in the notes view, like on raw reports. + field: { type: CustomFieldType.TEXT }, + textValue: { not: null }, + }, + }, + }, + ], }, select: { notes: true, @@ -90,6 +108,23 @@ export const getNotes = createAnalysisHandler({ name: Boolean(ctx.user.teamNumber), }, }, + customFieldAnswers: { + where: { + field: { type: CustomFieldType.TEXT }, + textValue: { not: null }, + }, + select: { + textValue: true, + field: { + select: { + name: true, + order: true, + createdAt: true, + uuid: true, + }, + }, + }, + }, }, orderBy: [ { teamMatchData: { tournament: { date: "desc" } } }, @@ -98,6 +133,25 @@ export const getNotes = createAnalysisHandler({ ], }); + // Text answers, blank-filtered and sorted into canonical field order. + const customTextAnswersFor = (report: (typeof noteData)[number]) => + report.customFieldAnswers + .filter((answer) => (answer.textValue ?? "").trim() !== "") + .sort( + (a, b) => + a.field.order - b.field.order || + a.field.createdAt.getTime() - b.field.createdAt.getTime() || + (a.field.uuid < b.field.uuid + ? -1 + : a.field.uuid > b.field.uuid + ? 1 + : 0), + ) + .map((answer) => ({ + name: answer.field.name, + value: (answer.textValue ?? "").trim(), + })); + if (Boolean(ctx.user.teamNumber)) { notesAndMatches = noteData.map((report) => ({ notes: report.notes, @@ -109,6 +163,7 @@ export const getNotes = createAnalysisHandler({ report.scouter.sourceTeamNumber === ctx.user.teamNumber ? report.scouter.name : undefined, + customTextAnswers: customTextAnswersFor(report), })); } else { notesAndMatches = noteData.map((report) => ({ @@ -117,6 +172,7 @@ export const getNotes = createAnalysisHandler({ robotBrokeDescription: report.robotBrokeDescription, tournamentName: report.teamMatchData.tournament.name, sourceTeam: report.scouter.sourceTeamNumber, + customTextAnswers: customTextAnswersFor(report), })); } diff --git a/src/handler/manager/scoutreports/getScoutReport.ts b/src/handler/manager/scoutreports/getScoutReport.ts index a3ee96ca..b87f4d55 100644 --- a/src/handler/manager/scoutreports/getScoutReport.ts +++ b/src/handler/manager/scoutreports/getScoutReport.ts @@ -54,11 +54,10 @@ export const getScoutReport = async ( const canModify = isOnSameTeam && user.role === UserRole.SCOUTING_LEAD; - // Custom field answers are scoped to the source team; cross-team viewers - // always get an empty array (key always present) - const customFieldAnswers = isOnSameTeam - ? await getAnswersForReport(params.data.uuid) - : []; + // Custom field answers display inline with their question names, so they + // read correctly for any viewer who can see this report, not just the + // source team. (Aggregate surfaces stay own-team-scoped.) + const customFieldAnswers = await getAnswersForReport(params.data.uuid); const { scouter, ...reportWithoutScouter } = scoutReport; const responseReport = { From 3a8a49eada1922e538cba1ff383b60e82114789a Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Mon, 3 Aug 2026 15:49:04 -0700 Subject: [PATCH 3/7] Link notes to raw reports and allow editing text answers getNotes returns each report's uuid so note cards can open the raw report. matchPageSpecificScouter returns canModify (scouting lead of the report's team) plus the answers' source team, so the client can gate editing and label the section ("Asked by your team" vs another team's). Answer views carry their own uuid. New endpoint PUT /manager/customfields/answers/:uuid lets a scouting lead of the field's team edit a text answer. --- .../customFields/customFieldShared.ts | 2 + .../matchPageSpecificScouter.ts | 16 +++ src/handler/analysis/teamLookUp/getNotes.ts | 4 + .../customfields/updateCustomFieldAnswer.ts | 99 +++++++++++++++++++ src/routes/manager/customfields.routes.ts | 29 ++++++ 5 files changed, 150 insertions(+) create mode 100644 src/handler/manager/customfields/updateCustomFieldAnswer.ts diff --git a/src/handler/analysis/customFields/customFieldShared.ts b/src/handler/analysis/customFields/customFieldShared.ts index 1870656a..11a61d4b 100644 --- a/src/handler/analysis/customFields/customFieldShared.ts +++ b/src/handler/analysis/customFields/customFieldShared.ts @@ -54,6 +54,7 @@ export const getActiveCustomFields = async ( // Shape of a custom field answer as exposed on raw-report responses // (reconciliation #6) export type CustomFieldAnswerView = { + uuid: string; fieldUuid: string; name: string; type: CustomFieldType; @@ -86,6 +87,7 @@ export const getAnswersForReport = async ( }); return answers.map((answer) => ({ + uuid: answer.uuid, fieldUuid: answer.fieldUuid, name: answer.field.name, type: answer.field.type, diff --git a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts index b5bbbf78..d8271673 100644 --- a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts +++ b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts @@ -15,6 +15,7 @@ import { autoPathScouter } from "./autoPathScouter.js"; import { averageScoutReport } from "../coreAnalysis/averageScoutReport.js"; import { createAnalysisHandler } from "../analysisHandler.js"; import { getAnswersForReport } from "../customFields/customFieldShared.js"; +import { UserRole } from "@prisma/client"; export const matchPageSpecificScouter = createAnalysisHandler({ params: { @@ -130,6 +131,21 @@ export const matchPageSpecificScouter = createAnalysisHandler({ // Handler is shouldCache: false, so computing per-request is fine. output.customFieldAnswers = await getAnswersForReport(scoutReport.uuid); + // Scouting leads of the report's own team may edit its text custom answers. + output.canModify = + ctx.user.teamNumber !== null && + ctx.user.role === UserRole.SCOUTING_LEAD && + scoutReport.scouter?.sourceTeamNumber === ctx.user.teamNumber; + + // The custom questions belong to the team whose scout answered them, which + // may not be the viewer's team (data sharing). The client labels the + // section accordingly. + output.customFieldsSourceTeam = + scoutReport.scouter?.sourceTeamNumber ?? null; + output.customFieldsAreOwnTeam = + ctx.user.teamNumber !== null && + scoutReport.scouter?.sourceTeamNumber === ctx.user.teamNumber; + return output; }, }); diff --git a/src/handler/analysis/teamLookUp/getNotes.ts b/src/handler/analysis/teamLookUp/getNotes.ts index 6d50e50a..49baf2e0 100644 --- a/src/handler/analysis/teamLookUp/getNotes.ts +++ b/src/handler/analysis/teamLookUp/getNotes.ts @@ -44,6 +44,7 @@ export const getNotes = createAnalysisHandler({ } let notesAndMatches: { + uuid: string; notes: string; robotBrokeDescription?: string; match: string; @@ -90,6 +91,7 @@ export const getNotes = createAnalysisHandler({ ], }, select: { + uuid: true, notes: true, robotBrokeDescription: true, teamMatchKey: true, @@ -154,6 +156,7 @@ export const getNotes = createAnalysisHandler({ if (Boolean(ctx.user.teamNumber)) { notesAndMatches = noteData.map((report) => ({ + uuid: report.uuid, notes: report.notes, match: report.teamMatchKey, robotBrokeDescription: report.robotBrokeDescription, @@ -167,6 +170,7 @@ export const getNotes = createAnalysisHandler({ })); } else { notesAndMatches = noteData.map((report) => ({ + uuid: report.uuid, notes: report.notes, match: report.teamMatchKey, robotBrokeDescription: report.robotBrokeDescription, diff --git a/src/handler/manager/customfields/updateCustomFieldAnswer.ts b/src/handler/manager/customfields/updateCustomFieldAnswer.ts new file mode 100644 index 00000000..a66421c5 --- /dev/null +++ b/src/handler/manager/customfields/updateCustomFieldAnswer.ts @@ -0,0 +1,99 @@ +import { Response } from "express"; +import prismaClient from "../../../prismaClient.js"; +import z from "zod"; +import { AuthenticatedRequest } from "../../../lib/middleware/requireAuth.js"; +import { CustomFieldType, UserRole } from "@prisma/client"; +import { invalidateCache } from "../../../lib/clearCache.js"; + +// Edit the text of a single TEXT custom field answer. Only a scouting lead of +// the team that owns the field may do this. Number/select answers are not +// editable here — those are corrected by resubmitting the scout report. +export const updateCustomFieldAnswer = async ( + req: AuthenticatedRequest, + res: Response, +): Promise => { + try { + if (req.tokenType === "apiKey") { + res + .status(403) + .json({ error: "This action cannot be performed using an API key" }); + return; + } + + const params = z + .object({ + uuid: z.string(), + value: z.string().trim().min(1).max(1000), + }) + .safeParse({ + uuid: req.params.uuid, + value: req.body.value, + }); + + if (!params.success) { + res.status(400).send({ + error: params, + displayError: + "Invalid input. Make sure you are using the correct input.", + }); + return; + } + + if ( + req.user.role !== UserRole.SCOUTING_LEAD || + req.user.teamNumber === null + ) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not a scouting lead on a team`, + displayError: + "You need to be a scouting lead to edit custom field answers", + }); + return; + } + + const answer = await prismaClient.customFieldAnswer.findUnique({ + where: { uuid: params.data.uuid }, + include: { + field: true, + scoutReport: { + select: { teamMatchData: { select: { teamNumber: true } } }, + }, + }, + }); + if (!answer) { + res.status(404).send({ + error: `Custom field answer with the uuid ${params.data.uuid} does not exist`, + displayError: "Answer not found", + }); + return; + } + if (answer.field.teamNumber !== req.user.teamNumber) { + res.status(403).send({ + error: `User with the id ${req.user.id} is not on the same team as the custom field ${answer.fieldUuid}`, + displayError: "Not authorized to edit this answer", + }); + return; + } + if (answer.field.type !== CustomFieldType.TEXT) { + res.status(400).send({ + error: `Only text custom field answers can be edited here (field type is ${answer.field.type})`, + displayError: "Only text answers can be edited here", + }); + return; + } + + const updated = await prismaClient.customFieldAnswer.update({ + where: { uuid: answer.uuid }, + data: { textValue: params.data.value }, + }); + + // Clear the scouted team's cached analysis (Team Lookup notes) so the edit + // shows up. The raw report itself is uncached. + await invalidateCache(answer.scoutReport.teamMatchData.teamNumber, []); + + res.status(200).send(updated); + } catch (error) { + console.error(error); + res.status(500).send({ error: error, displayError: "Error" }); + } +}; diff --git a/src/routes/manager/customfields.routes.ts b/src/routes/manager/customfields.routes.ts index f99ad01c..5a1d714f 100644 --- a/src/routes/manager/customfields.routes.ts +++ b/src/routes/manager/customfields.routes.ts @@ -9,6 +9,7 @@ import { reorderCustomFields } from "../../handler/manager/customfields/reorderC import { archiveCustomField } from "../../handler/manager/customfields/archiveCustomField.js"; import { unarchiveCustomField } from "../../handler/manager/customfields/unarchiveCustomField.js"; import { deleteCustomField } from "../../handler/manager/customfields/deleteCustomField.js"; +import { updateCustomFieldAnswer } from "../../handler/manager/customfields/updateCustomFieldAnswer.js"; import { registry } from "../../lib/openapi.js"; import { z } from "zod"; @@ -130,6 +131,33 @@ registry.registerPath({ security: [{ bearerAuth: [] }], }); +registry.registerPath({ + method: "put", + path: "/v1/manager/customfields/answers/{uuid}", + tags: ["Manager - Custom Fields"], + summary: + "Edit a text custom field answer (SCOUTING_LEAD of the field's team)", + request: { + params: z.object({ uuid: z.string() }), + body: { + content: { + "application/json": { + schema: z.object({ value: z.string().min(1).max(1000) }), + }, + }, + }, + }, + responses: { + 200: { description: "Updated" }, + 400: { description: "Invalid request or non-text field" }, + 401: { description: "Unauthorized" }, + 403: { description: "Not a scouting lead of the field's team" }, + 404: { description: "Answer not found" }, + 500: { description: "Server error" }, + }, + security: [{ bearerAuth: [] }], +}); + registry.registerPath({ method: "put", path: "/v1/manager/customfields/{uuid}", @@ -228,6 +256,7 @@ router.post("/", addCustomField); // Must be registered before the /:uuid routes router.put("/order", reorderCustomFields); +router.put("/answers/:uuid", updateCustomFieldAnswer); router.put("/:uuid", updateCustomField); router.post("/:uuid/archive", archiveCustomField); From 99013b705d905d104236a95b150cf50324b8be8f Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Mon, 3 Aug 2026 22:54:11 -0700 Subject: [PATCH 4/7] Don't reject an entire scout report over one bad custom answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CustomFieldAnswersInputSchema (strict: value string<=1000 | number | string[<=80]<=30, fieldUuid required) was embedded in the top-level .parse, so a single oversized/malformed custom answer threw a ZodError and 400'd the whole match report — contradicting the documented lenient design. Parse the field with a permissive wire schema (array of unknown, length-capped) and let validateCustomFieldAnswers do all per-answer validation by dropping/clamping (now also guarding non-object entries, non-string fieldUuids, over-long text, and oversized multi-selects). The strict schema is kept for the OpenAPI docs. --- .../validateCustomFieldAnswers.ts | 56 +++++++++++++------ .../manager/scoutreports/addScoutReport.ts | 4 +- .../scoutreports/addScoutReportDashboard.ts | 4 +- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/src/handler/manager/customfields/validateCustomFieldAnswers.ts b/src/handler/manager/customfields/validateCustomFieldAnswers.ts index 8efe55cd..6024199c 100644 --- a/src/handler/manager/customfields/validateCustomFieldAnswers.ts +++ b/src/handler/manager/customfields/validateCustomFieldAnswers.ts @@ -2,9 +2,10 @@ import z from "zod"; import prismaClient from "../../../prismaClient.js"; import { CustomFieldType } from "@prisma/client"; -// Wire shape for custom field answers on scout report submission +// Descriptive wire shape for custom field answers on scout report submission // (reconciliation decision 1): one `value` key for all types — -// TEXT/SINGLE_SELECT -> string, NUMBER -> number, MULTI_SELECT -> string[] +// TEXT/SINGLE_SELECT -> string, NUMBER -> number, MULTI_SELECT -> string[]. +// This schema documents the request body in the OpenAPI spec. export const CustomFieldAnswersInputSchema = z .array( z.object({ @@ -18,9 +19,17 @@ export const CustomFieldAnswersInputSchema = z ) .max(50); -export type CustomFieldAnswersInput = z.infer< - typeof CustomFieldAnswersInputSchema ->; +// Lenient schema used for the ACTUAL request parse. A malformed or oversized +// custom answer must never reject the whole scout report (reconciliation +// decision 4), so at parse time we only cap the array length (abuse guard) and +// accept each entry as-is; validateCustomFieldAnswers below does all per-answer +// validation by dropping/clamping rather than throwing. Using the strict schema +// above here would 400 the entire match report over e.g. a 1001-char text field +// or a 31-option multi-select. +export const CustomFieldAnswersWireSchema = z.array(z.unknown()).max(50); + +const MAX_TEXT_LENGTH = 1000; +const MAX_SELECTIONS = 30; export type ValidatedCustomFieldAnswerRow = { fieldUuid: string; @@ -31,22 +40,34 @@ export type ValidatedCustomFieldAnswerRow = { /** * Lenient per-answer validation (reconciliation decision 4): a bad custom - * answer must never reject a whole scout report. Unknown/wrong-team uuids, - * type mismatches, out-of-options selections, duplicate fieldUuids (first - * occurrence wins), blank text, and empty multi-selects are silently dropped. - * Archived fields are accepted and stored. + * answer must never reject a whole scout report. Non-object entries, missing/ + * non-string fieldUuids, unknown/wrong-team uuids, type mismatches, out-of- + * options selections, duplicate fieldUuids (first occurrence wins), blank text, + * and empty multi-selects are silently dropped. Over-long text is clamped and + * multi-selects are de-duplicated/capped. Archived fields are accepted/stored. */ export const validateCustomFieldAnswers = async ( sourceTeamNumber: number, - answers: CustomFieldAnswersInput | undefined, + answers: unknown[] | undefined, ): Promise => { if (!answers || answers.length === 0) { return []; } + // Extract a (fieldUuid, value) pair from each loose entry, dropping anything + // that isn't a well-formed object with a string fieldUuid. + const parsed: { fieldUuid: string; value: unknown }[] = []; + for (const raw of answers) { + if (typeof raw !== "object" || raw === null) continue; + const fieldUuid = (raw as Record).fieldUuid; + if (typeof fieldUuid !== "string") continue; + parsed.push({ fieldUuid, value: (raw as Record).value }); + } + if (parsed.length === 0) return []; + const fields = await prismaClient.customField.findMany({ where: { - uuid: { in: [...new Set(answers.map((answer) => answer.fieldUuid))] }, + uuid: { in: [...new Set(parsed.map((answer) => answer.fieldUuid))] }, teamNumber: sourceTeamNumber, }, }); @@ -55,7 +76,7 @@ export const validateCustomFieldAnswers = async ( const rows: ValidatedCustomFieldAnswerRow[] = []; const seenFieldUuids = new Set(); - for (const answer of answers) { + for (const answer of parsed) { if (seenFieldUuids.has(answer.fieldUuid)) continue; seenFieldUuids.add(answer.fieldUuid); @@ -65,7 +86,7 @@ export const validateCustomFieldAnswers = async ( switch (field.type) { case CustomFieldType.TEXT: { if (typeof answer.value !== "string") break; - const trimmed = answer.value.trim(); + const trimmed = answer.value.trim().slice(0, MAX_TEXT_LENGTH); if (trimmed.length === 0) break; rows.push({ fieldUuid: field.uuid, @@ -99,9 +120,12 @@ export const validateCustomFieldAnswers = async ( } case CustomFieldType.MULTI_SELECT: { if (!Array.isArray(answer.value)) break; - const selections = [...new Set(answer.value)].filter((option) => - field.options.includes(option), - ); + const selections = [...new Set(answer.value)] + .filter( + (option): option is string => + typeof option === "string" && field.options.includes(option), + ) + .slice(0, MAX_SELECTIONS); if (selections.length === 0) break; rows.push({ fieldUuid: field.uuid, diff --git a/src/handler/manager/scoutreports/addScoutReport.ts b/src/handler/manager/scoutreports/addScoutReport.ts index d2c34ce1..65cee872 100644 --- a/src/handler/manager/scoutreports/addScoutReport.ts +++ b/src/handler/manager/scoutreports/addScoutReport.ts @@ -21,7 +21,7 @@ import { import { sendWarningToSlack } from "../../slack/sendWarningNotification.js"; import { invalidateCache } from "../../../lib/clearCache.js"; import { - CustomFieldAnswersInputSchema, + CustomFieldAnswersWireSchema, validateCustomFieldAnswers, } from "../customfields/validateCustomFieldAnswers.js"; @@ -131,7 +131,7 @@ export const addScoutReport = async ( scouterUuid: z.string(), teamNumber: z.number(), appVersion: z.string().optional(), - customFieldAnswers: CustomFieldAnswersInputSchema.optional(), + customFieldAnswers: CustomFieldAnswersWireSchema.optional(), }) .parse(req.body); diff --git a/src/handler/manager/scoutreports/addScoutReportDashboard.ts b/src/handler/manager/scoutreports/addScoutReportDashboard.ts index 909022ea..fd42fdef 100644 --- a/src/handler/manager/scoutreports/addScoutReportDashboard.ts +++ b/src/handler/manager/scoutreports/addScoutReportDashboard.ts @@ -27,7 +27,7 @@ import { removeOrphanedStartEvents, } from "./addScoutReport.js"; import { - CustomFieldAnswersInputSchema, + CustomFieldAnswersWireSchema, validateCustomFieldAnswers, } from "../customfields/validateCustomFieldAnswers.js"; @@ -73,7 +73,7 @@ export const addScoutReportDashboard = async ( scouterUuid: z.string(), teamNumber: z.number(), appVersion: z.string().optional(), - customFieldAnswers: CustomFieldAnswersInputSchema.optional(), + customFieldAnswers: CustomFieldAnswersWireSchema.optional(), }) .parse(req.body); From 2f53ee6d55d4b2ebd1f085e5083933ede81580e2 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Mon, 3 Aug 2026 22:56:52 -0700 Subject: [PATCH 5/7] Make the no-schedule 404 in super-scouting generation actually reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NO_SCHEDULE guard checked matchesResponse/teamsResponse for null, but it sat after the code already dereferenced matchesResponse.headers.etag (both are always non-null there since the try/catch returns or throws), so it could never fire and the handler's 404 "No schedule available" path was dead. Replace it with a real check — no qualification matches after filtering — placed before the schedule is built, so tournaments without a posted schedule return 404 instead of an empty one. --- .../manager/scoutershifts/generateSchedule.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/handler/manager/scoutershifts/generateSchedule.ts b/src/handler/manager/scoutershifts/generateSchedule.ts index 6fb2b1c9..697a07e5 100644 --- a/src/handler/manager/scoutershifts/generateSchedule.ts +++ b/src/handler/manager/scoutershifts/generateSchedule.ts @@ -79,14 +79,19 @@ const generateSchedule = async ( }, }); - if (!matchesResponse || !teamsResponse) { - throw "NO_SCHEDULE"; - } - matchesResponse.data = matchesResponse.data.filter( (match: any) => match.comp_level === "qm", ); + // No qualification matches means there's no schedule to build shifts from, so + // surface the intended 404 ("No schedule available") rather than returning an + // empty schedule. The previous guard checked matchesResponse/teamsResponse for + // null *after* already dereferencing matchesResponse.headers.etag above, so it + // could never fire. + if (matchesResponse.data.length === 0) { + throw "NO_SCHEDULE"; + } + matchesResponse.data.sort( (a: any, b: any) => a.match_number - b.match_number, ); From 1fbd509c5016ae3bf4ea4038759303fb3707f1cc Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Mon, 3 Aug 2026 22:59:04 -0700 Subject: [PATCH 6/7] Only import elim matches for known double-elim brackets; drop debug log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playoff_type selection fell through to the 4-team double-elim map for ANY type other than 10, so a single-elimination (or other non-double-elim) event had its elim matches forced through the 4-team ordering — mis-numbering some and silently dropping the rest. Map only playoff_type 10 (8-team) and 11 (4-team); any other type gets an empty order map so elim matches are skipped rather than mis-imported (qualification import is unaffected). Also remove a leftover console.log that dumped the entire TBA event payload on every import. --- src/handler/manager/addTournamentMatches.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/handler/manager/addTournamentMatches.ts b/src/handler/manager/addTournamentMatches.ts index a8f31cfe..3f778115 100644 --- a/src/handler/manager/addTournamentMatches.ts +++ b/src/handler/manager/addTournamentMatches.ts @@ -50,8 +50,6 @@ export const addTournamentMatches = async ( const json: unknown = await eventResponse.json(); - console.log(JSON.stringify(json, null, 2)); - const event = z .object({ remap_teams: z.record(z.string(), z.string()).nullish(), @@ -121,10 +119,17 @@ export const addTournamentMatches = async ( ["f1m2", 7], ]); + // Only the two supported double-elim brackets have a known match ordering + // (TBA playoff_type 10 = 8-team, 11 = 4-team). For any other bracket type + // (e.g. single elimination) an empty map means the elim branch's + // `playoffMatchOrder.get(...)` misses and those matches are skipped, rather + // than being mis-numbered by forcing them through the 4-team map. const playoffMatchOrder = event.playoff_type === 10 ? eightTeamDoubleElimPlayoffMatchOrder - : fourTeamDoubleElimPlayoffMatchOrder; + : event.playoff_type === 11 + ? fourTeamDoubleElimPlayoffMatchOrder + : new Map(); // For each match in the tournament matchesResponse.data.sort( From 8bb3df4393586e02f6057ca5f36f44e47daf3bc6 Mon Sep 17 00:00:00 2001 From: MangoSwirl Date: Mon, 3 Aug 2026 23:00:10 -0700 Subject: [PATCH 7/7] Don't leak the raw error object from the unauthenticated /checkmatch route checkMatchExists (security: [], no requireAuth) returned the raw caught error via res.status(500).send(error) on any DB/TBA failure, disclosing internal details to anonymous callers. Log it server-side and return a generic "Internal server error" instead. --- src/handler/manager/checkMatchExists.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/handler/manager/checkMatchExists.ts b/src/handler/manager/checkMatchExists.ts index a8bd44c0..7c60d423 100644 --- a/src/handler/manager/checkMatchExists.ts +++ b/src/handler/manager/checkMatchExists.ts @@ -49,7 +49,9 @@ export const checkMatchExists = async ( } res.status(404).send("MATCH_NOT_FOUND"); } catch (error) { - console.log(error); - res.status(500).send(error); + console.error(error); + // This route is unauthenticated (security: []), so don't return the raw + // error object (internal detail disclosure) — log it and send a generic message. + res.status(500).send("Internal server error"); } };