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..11a61d4b --- /dev/null +++ b/src/handler/analysis/customFields/customFieldShared.ts @@ -0,0 +1,185 @@ +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 = { + uuid: string; + 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) => ({ + uuid: answer.uuid, + 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..d8271673 100644 --- a/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts +++ b/src/handler/analysis/specificMatchPage/matchPageSpecificScouter.ts @@ -14,6 +14,8 @@ import { 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: { @@ -48,6 +50,11 @@ export const matchPageSpecificScouter = createAnalysisHandler({ autoClimb: true, feederTypes: true, accuracy: true, + scouter: { + select: { + sourceTeamNumber: true, + }, + }, }, }); @@ -117,6 +124,28 @@ export const matchPageSpecificScouter = createAnalysisHandler({ output[metricToName[metric]] = aggregateData[metric]; } + // 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); + + // 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/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/analysis/teamLookUp/getNotes.ts b/src/handler/analysis/teamLookUp/getNotes.ts index d7ca9b56..49baf2e0 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, @@ -43,12 +44,18 @@ export const getNotes = createAnalysisHandler({ } let notesAndMatches: { + uuid: string; notes: string; robotBrokeDescription?: string; match: string; 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,11 +74,24 @@ 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: { + uuid: true, notes: true, robotBrokeDescription: true, teamMatchKey: true, @@ -90,6 +110,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,8 +135,28 @@ 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) => ({ + uuid: report.uuid, notes: report.notes, match: report.teamMatchKey, robotBrokeDescription: report.robotBrokeDescription, @@ -109,14 +166,17 @@ export const getNotes = createAnalysisHandler({ report.scouter.sourceTeamNumber === ctx.user.teamNumber ? report.scouter.name : undefined, + customTextAnswers: customTextAnswersFor(report), })); } else { notesAndMatches = noteData.map((report) => ({ + uuid: report.uuid, notes: report.notes, match: report.teamMatchKey, robotBrokeDescription: report.robotBrokeDescription, tournamentName: report.teamMatchData.tournament.name, sourceTeam: report.scouter.sourceTeamNumber, + customTextAnswers: customTextAnswersFor(report), })); } 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( 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"); } }; 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/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/handler/manager/customfields/validateCustomFieldAnswers.ts b/src/handler/manager/customfields/validateCustomFieldAnswers.ts new file mode 100644 index 00000000..6024199c --- /dev/null +++ b/src/handler/manager/customfields/validateCustomFieldAnswers.ts @@ -0,0 +1,142 @@ +import z from "zod"; +import prismaClient from "../../../prismaClient.js"; +import { CustomFieldType } from "@prisma/client"; + +// 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[]. +// This schema documents the request body in the OpenAPI spec. +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); + +// 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; + 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. 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: 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(parsed.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 parsed) { + 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().slice(0, MAX_TEXT_LENGTH); + 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): option is string => + typeof option === "string" && field.options.includes(option), + ) + .slice(0, MAX_SELECTIONS); + 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/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, ); diff --git a/src/handler/manager/scoutreports/addScoutReport.ts b/src/handler/manager/scoutreports/addScoutReport.ts index cd4ece4e..65cee872 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 { + CustomFieldAnswersWireSchema, + 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: CustomFieldAnswersWireSchema.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..fd42fdef 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 { + CustomFieldAnswersWireSchema, + 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: CustomFieldAnswersWireSchema.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..b87f4d55 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,23 @@ export const getScoutReport = async ( const canModify = isOnSameTeam && user.role === UserRole.SCOUTING_LEAD; + // 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 = { ...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..5a1d714f --- /dev/null +++ b/src/routes/manager/customfields.routes.ts @@ -0,0 +1,266 @@ +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 { updateCustomFieldAnswer } from "../../handler/manager/customfields/updateCustomFieldAnswer.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/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}", + 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("/answers/:uuid", updateCustomFieldAnswer); + +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), }), }, },