Shoraky commited on
Commit
3304e46
·
1 Parent(s): 7ceab81

Require complete player profile details

Browse files
Files changed (2) hide show
  1. src/admin.ts +24 -20
  2. src/server.ts +38 -44
src/admin.ts CHANGED
@@ -15,11 +15,16 @@ const profileSchema = z.object({
15
  academy: z.string().trim().max(150).nullable(), role: z.string().trim().max(50).nullable(),
16
  }).strict();
17
  const playerSchema = z.object({
18
- fname: z.string().trim().min(1).max(100), lname: z.string().trim().max(100).optional().default(""),
19
- age: z.number().int().min(4).max(60).nullable().optional(),
20
- heightCm: z.number().min(100).max(230).nullable().optional(), weightKg: z.number().min(20).max(150).nullable().optional(),
21
- club: z.string().trim().max(150).nullable().optional(), position: z.string().trim().max(50).nullable().optional(),
22
- gender: z.enum(["male", "female"]).nullable().optional(), userId: uuid.nullable().optional(),
 
 
 
 
 
23
  }).strict();
24
 
25
  function fail(res: Response, status: number, error: string) { return res.status(status).json({ error }); }
@@ -27,13 +32,6 @@ function asyncRoute(handler: (req: Request, res: Response) => Promise<unknown>)
27
  return (req: Request, res: Response, next: NextFunction) => { void handler(req, res).catch(next); };
28
  }
29
  async function admin(req: Request, res: Response) { return requireAdmin(req, res); }
30
- function ageDate(age?: number | null) {
31
- if (age == null) return null;
32
- const birth = new Date();
33
- birth.setUTCFullYear(birth.getUTCFullYear() - age);
34
- return birth.toISOString().slice(0, 10);
35
- }
36
-
37
  async function analysisRequest(path: string, userId: string, method: string, isAdmin = true, body?: unknown) {
38
  const response = await fetch(`${env.ANALYSIS_API_URL}/api${path}`, {
39
  method,
@@ -198,15 +196,15 @@ export function registerAdminRoutes(app: Express) {
198
  if (!data) return fail(res, 404, "Player owner not found.");
199
  }
200
  const { data, error } = await supabaseAdmin.rpc("admin_create_player", {
201
- p_user_id: input.data.userId ?? null,
202
  p_fname: input.data.fname,
203
- p_lname: input.data.lname,
204
- p_bdate: ageDate(input.data.age),
205
- p_height: input.data.heightCm ?? null,
206
- p_weight: input.data.weightKg ?? null,
207
  p_club: input.data.club || null,
208
- p_position: input.data.position || null,
209
- p_gender: input.data.gender ?? null,
210
  });
211
  if (error) throw new Error(`Unable to add player: ${error.message}`);
212
  return res.status(201).json({ player: data });
@@ -215,7 +213,7 @@ export function registerAdminRoutes(app: Express) {
215
  app.patch("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
216
  if (!await admin(req, res)) return;
217
  const id = uuid.safeParse(req.params.playerId);
218
- const input = z.object({ userId: uuid.nullable() }).strict().safeParse(req.body);
219
  if (!id.success || !input.success) return fail(res, 400, "Choose a valid owner.");
220
  if (input.data.userId) {
221
  const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
@@ -357,6 +355,12 @@ export function registerAdminRoutes(app: Express) {
357
  const { data: target, error } = await supabaseAdmin.from("users").select("user_id,account_role,image_path").eq("user_id", id.data).maybeSingle();
358
  if (error) throw new Error(`Unable to load account: ${error.message}`);
359
  if (!target) return res.sendStatus(404);
 
 
 
 
 
 
360
  if (String(target.account_role).toLowerCase() === "admin") {
361
  return fail(res, 409, "Administrator accounts cannot be deleted here. Change administrator access directly in the database first.");
362
  }
 
15
  academy: z.string().trim().max(150).nullable(), role: z.string().trim().max(50).nullable(),
16
  }).strict();
17
  const playerSchema = z.object({
18
+ fname: z.string().trim().min(1).max(100), lname: z.string().trim().max(100).nullable().optional().default(null),
19
+ bdate: date.refine((value) => {
20
+ const today = new Date();
21
+ const [year, month, day] = value.split("-").map(Number);
22
+ const age = today.getUTCFullYear() - year - Number(today.getUTCMonth() + 1 < month || (today.getUTCMonth() + 1 === month && today.getUTCDate() < day));
23
+ return age >= 4 && age <= 60;
24
+ }, "Enter a valid birth date for a player aged 4 to 60."),
25
+ heightCm: z.number().min(100).max(230), weightKg: z.number().min(20).max(150),
26
+ club: z.string().trim().max(150).nullable().optional(), position: z.string().trim().min(1).max(50),
27
+ gender: z.enum(["male", "female"]), userId: uuid,
28
  }).strict();
29
 
30
  function fail(res: Response, status: number, error: string) { return res.status(status).json({ error }); }
 
32
  return (req: Request, res: Response, next: NextFunction) => { void handler(req, res).catch(next); };
33
  }
34
  async function admin(req: Request, res: Response) { return requireAdmin(req, res); }
 
 
 
 
 
 
 
35
  async function analysisRequest(path: string, userId: string, method: string, isAdmin = true, body?: unknown) {
36
  const response = await fetch(`${env.ANALYSIS_API_URL}/api${path}`, {
37
  method,
 
196
  if (!data) return fail(res, 404, "Player owner not found.");
197
  }
198
  const { data, error } = await supabaseAdmin.rpc("admin_create_player", {
199
+ p_user_id: input.data.userId,
200
  p_fname: input.data.fname,
201
+ p_lname: input.data.lname?.trim() || null,
202
+ p_bdate: input.data.bdate,
203
+ p_height: input.data.heightCm,
204
+ p_weight: input.data.weightKg,
205
  p_club: input.data.club || null,
206
+ p_position: input.data.position,
207
+ p_gender: input.data.gender,
208
  });
209
  if (error) throw new Error(`Unable to add player: ${error.message}`);
210
  return res.status(201).json({ player: data });
 
213
  app.patch("/api/admin/players/:playerId", asyncRoute(async (req, res) => {
214
  if (!await admin(req, res)) return;
215
  const id = uuid.safeParse(req.params.playerId);
216
+ const input = z.object({ userId: uuid }).strict().safeParse(req.body);
217
  if (!id.success || !input.success) return fail(res, 400, "Choose a valid owner.");
218
  if (input.data.userId) {
219
  const { data, error } = await supabaseAdmin.from("users").select("user_id").eq("user_id", input.data.userId).maybeSingle();
 
355
  const { data: target, error } = await supabaseAdmin.from("users").select("user_id,account_role,image_path").eq("user_id", id.data).maybeSingle();
356
  if (error) throw new Error(`Unable to load account: ${error.message}`);
357
  if (!target) return res.sendStatus(404);
358
+ const { count: playerCount, error: playerCountError } = await supabaseAdmin.from("players")
359
+ .select("player_id", { count: "exact", head: true }).eq("user_id", id.data);
360
+ if (playerCountError) throw new Error(`Unable to check account players: ${playerCountError.message}`);
361
+ if ((playerCount ?? 0) > 0) {
362
+ return fail(res, 409, "Transfer this account's players to another user before deleting the account.");
363
+ }
364
  if (String(target.account_role).toLowerCase() === "admin") {
365
  return fail(res, 409, "Administrator accounts cannot be deleted here. Change administrator access directly in the database first.");
366
  }
src/server.ts CHANGED
@@ -73,30 +73,37 @@ const actionReviewSchema = z.object({
73
  }).strict();
74
  const playerProfileSchema = z.object({
75
  fname: z.string().trim().min(1).max(100).optional(),
76
- lname: z.string().trim().max(100).optional(),
77
  name: z.string().trim().max(200).optional(),
78
- age: z.string().regex(/^(|\d{1,2})$/),
79
- heightCm: z.string().regex(/^(|\d{2,3}(?:\.\d{1,2})?)$/),
80
- weightKg: z.string().regex(/^(|\d{1,3}(?:\.\d{1,2})?)$/),
81
  club: z.string().trim().max(150),
82
- position: z.string().trim().max(50),
83
- gender: z.union([z.enum(["male", "female"]), z.literal("")]),
84
  }).strict().transform((input) => {
85
  const legacyNameParts = (input.name ?? "").split(/\s+/).filter(Boolean);
86
  const fname = input.fname ?? legacyNameParts.shift() ?? "";
87
  const lname = input.lname ?? legacyNameParts.join(" ");
88
  return { ...input, fname, lname };
89
- }).refine((input) => Boolean(input.fname), { message: "First name is required." });
 
90
  const playerCreateSchema = z.object({
91
  fname: z.string().trim().min(1).max(100),
92
- lname: z.string().trim().max(100).optional().default(""),
93
- age: z.string().regex(/^(|\d{1,2})$/),
94
- heightCm: z.string().regex(/^(|\d{2,3}(?:\.\d{1,2})?)$/),
95
- weightKg: z.string().regex(/^(|\d{1,3}(?:\.\d{1,2})?)$/),
96
  club: z.string().trim().max(150),
97
- position: z.string().trim().max(50),
98
- gender: z.union([z.enum(["male", "female"]), z.literal("")]),
99
- }).strict();
 
 
 
 
 
 
100
 
101
  function ageFromBirthday(birthday: string | null): string {
102
  if (!birthday) return "";
@@ -315,28 +322,21 @@ app.post("/api/players", async (req, res, next) => {
315
  if (!current) return;
316
  if (!await enforcePlayerQuota(current.user.id, current.subscription.player_limit == null ? null : Number(current.subscription.player_limit), res)) return;
317
  const input = playerCreateSchema.parse(req.body);
318
- const age = input.age ? Number(input.age) : null;
319
- const height = input.heightCm ? Number(input.heightCm) : null;
320
- const weight = input.weightKg ? Number(input.weightKg) : null;
321
- if ((age !== null && (age < 4 || age > 60)) || (height !== null && (height < 100 || height > 230)) || (weight !== null && (weight < 20 || weight > 150))) {
322
  return res.status(400).json({ error: "Check the player's age, height and weight." });
323
  }
324
- let birthday: string | null = null;
325
- if (age !== null) {
326
- const date = new Date();
327
- date.setUTCFullYear(date.getUTCFullYear() - age);
328
- birthday = date.toISOString().slice(0, 10);
329
- }
330
  const { data, error } = await supabaseAdmin.from("players").insert({
331
  user_id: current.user.id,
332
  fname: input.fname,
333
- lname: input.lname || "",
334
- bdate: birthday,
335
  height,
336
  weight,
337
  club: input.club || null,
338
- position: input.position || null,
339
- gender: input.gender || null,
340
  }).select("player_id,fname,lname,position,club,gender,image_path").single();
341
  if (error?.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
342
  return res.status(403).json({ error: "You have used all player slots included in this plan period. Renew or upgrade your plan to add another player.", code: "quota_exceeded", resource: "players" });
@@ -365,32 +365,24 @@ app.put("/api/players/:playerId", async (req, res, next) => {
365
  const id = z.string().uuid().safeParse(req.params.playerId);
366
  if (!id.success) return res.status(404).json({ error: "Player not found." });
367
  const input = playerProfileSchema.parse(req.body);
368
- const age = input.age ? Number(input.age) : null;
369
- const height = input.heightCm ? Number(input.heightCm) : null;
370
- const weight = input.weightKg ? Number(input.weightKg) : null;
371
- if ((age !== null && (age < 4 || age > 60)) || (height !== null && (height < 100 || height > 230)) || (weight !== null && (weight < 20 || weight > 150))) {
372
  return res.status(400).json({ error: "Check the player's age, height and weight." });
373
  }
374
  const { data: existing, error: readError } = await supabaseAdmin.from("players")
375
- .select("player_id,bdate").eq("player_id", id.data).eq("user_id", current.user.id).maybeSingle();
376
  if (readError) throw new Error(`Unable to load player: ${readError.message}`);
377
  if (!existing) return res.status(404).json({ error: "Player not found." });
378
- let birthday = existing.bdate;
379
- if (age === null) birthday = null;
380
- else if (ageFromBirthday(existing.bdate) !== String(age)) {
381
- const date = new Date();
382
- date.setUTCFullYear(date.getUTCFullYear() - age);
383
- birthday = date.toISOString().slice(0, 10);
384
- }
385
  const { error } = await supabaseAdmin.from("players").update({
386
  fname: input.fname,
387
- lname: input.lname || "",
388
- bdate: birthday,
389
  height,
390
  weight,
391
  club: input.club || null,
392
- position: input.position || null,
393
- gender: input.gender || null,
394
  }).eq("player_id", id.data).eq("user_id", current.user.id);
395
  if (error) throw new Error(`Unable to update player: ${error.message}`);
396
  return res.json({ ok: true });
@@ -503,6 +495,7 @@ app.get("/api/players-overview", async (req, res, next) => {
503
  lname: row.lname ?? "",
504
  name: [row.fname, row.lname].filter(Boolean).join(" "),
505
  age: ageFromBirthday(row.bdate),
 
506
  heightCm: row.height == null ? "" : String(row.height),
507
  weightKg: row.weight == null ? "" : String(row.weight),
508
  club: row.club ?? "", position: row.position ?? "", gender: row.gender ?? null,
@@ -586,6 +579,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
586
  playerId: player.player_id, fname: player.fname ?? "", lname: player.lname ?? "", name: [player.fname, player.lname].filter(Boolean).join(" "),
587
  isOwner: player.user_id === current.user.id,
588
  age: ageFromBirthday(player.bdate),
 
589
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
590
  club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
591
  imagePath: player.image_path ?? null,
 
73
  }).strict();
74
  const playerProfileSchema = z.object({
75
  fname: z.string().trim().min(1).max(100).optional(),
76
+ lname: z.string().trim().max(100).nullable().optional(),
77
  name: z.string().trim().max(200).optional(),
78
+ bdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
79
+ heightCm: z.string().regex(/^\d{2,3}(?:\.\d{1,2})?$/),
80
+ weightKg: z.string().regex(/^\d{1,3}(?:\.\d{1,2})?$/),
81
  club: z.string().trim().max(150),
82
+ position: z.string().trim().min(1).max(50),
83
+ gender: z.enum(["male", "female"]),
84
  }).strict().transform((input) => {
85
  const legacyNameParts = (input.name ?? "").split(/\s+/).filter(Boolean);
86
  const fname = input.fname ?? legacyNameParts.shift() ?? "";
87
  const lname = input.lname ?? legacyNameParts.join(" ");
88
  return { ...input, fname, lname };
89
+ }).refine((input) => Boolean(input.fname), { message: "First name is required." })
90
+ .refine((input) => validPlayerBirthDate(input.bdate), { path: ["bdate"], message: "Enter a valid birth date for a player aged 4 to 60." });
91
  const playerCreateSchema = z.object({
92
  fname: z.string().trim().min(1).max(100),
93
+ lname: z.string().trim().max(100).nullable().optional().default(null),
94
+ bdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
95
+ heightCm: z.string().regex(/^\d{2,3}(?:\.\d{1,2})?$/),
96
+ weightKg: z.string().regex(/^\d{1,3}(?:\.\d{1,2})?$/),
97
  club: z.string().trim().max(150),
98
+ position: z.string().trim().min(1).max(50),
99
+ gender: z.enum(["male", "female"]),
100
+ }).strict().refine((input) => validPlayerBirthDate(input.bdate), { path: ["bdate"], message: "Enter a valid birth date for a player aged 4 to 60." });
101
+
102
+ function validPlayerBirthDate(value: string) {
103
+ const date = new Date(`${value}T00:00:00.000Z`);
104
+ return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value &&
105
+ Number(ageFromBirthday(value)) >= 4 && Number(ageFromBirthday(value)) <= 60;
106
+ }
107
 
108
  function ageFromBirthday(birthday: string | null): string {
109
  if (!birthday) return "";
 
322
  if (!current) return;
323
  if (!await enforcePlayerQuota(current.user.id, current.subscription.player_limit == null ? null : Number(current.subscription.player_limit), res)) return;
324
  const input = playerCreateSchema.parse(req.body);
325
+ const height = Number(input.heightCm);
326
+ const weight = Number(input.weightKg);
327
+ if (height < 100 || height > 230 || weight < 20 || weight > 150) {
 
328
  return res.status(400).json({ error: "Check the player's age, height and weight." });
329
  }
 
 
 
 
 
 
330
  const { data, error } = await supabaseAdmin.from("players").insert({
331
  user_id: current.user.id,
332
  fname: input.fname,
333
+ lname: input.lname?.trim() || null,
334
+ bdate: input.bdate,
335
  height,
336
  weight,
337
  club: input.club || null,
338
+ position: input.position,
339
+ gender: input.gender,
340
  }).select("player_id,fname,lname,position,club,gender,image_path").single();
341
  if (error?.code === "P0001" && error.message.includes("subscription_quota_exceeded")) {
342
  return res.status(403).json({ error: "You have used all player slots included in this plan period. Renew or upgrade your plan to add another player.", code: "quota_exceeded", resource: "players" });
 
365
  const id = z.string().uuid().safeParse(req.params.playerId);
366
  if (!id.success) return res.status(404).json({ error: "Player not found." });
367
  const input = playerProfileSchema.parse(req.body);
368
+ const height = Number(input.heightCm);
369
+ const weight = Number(input.weightKg);
370
+ if (height < 100 || height > 230 || weight < 20 || weight > 150) {
 
371
  return res.status(400).json({ error: "Check the player's age, height and weight." });
372
  }
373
  const { data: existing, error: readError } = await supabaseAdmin.from("players")
374
+ .select("player_id").eq("player_id", id.data).eq("user_id", current.user.id).maybeSingle();
375
  if (readError) throw new Error(`Unable to load player: ${readError.message}`);
376
  if (!existing) return res.status(404).json({ error: "Player not found." });
 
 
 
 
 
 
 
377
  const { error } = await supabaseAdmin.from("players").update({
378
  fname: input.fname,
379
+ lname: input.lname?.trim() || null,
380
+ bdate: input.bdate,
381
  height,
382
  weight,
383
  club: input.club || null,
384
+ position: input.position,
385
+ gender: input.gender,
386
  }).eq("player_id", id.data).eq("user_id", current.user.id);
387
  if (error) throw new Error(`Unable to update player: ${error.message}`);
388
  return res.json({ ok: true });
 
495
  lname: row.lname ?? "",
496
  name: [row.fname, row.lname].filter(Boolean).join(" "),
497
  age: ageFromBirthday(row.bdate),
498
+ bdate: row.bdate,
499
  heightCm: row.height == null ? "" : String(row.height),
500
  weightKg: row.weight == null ? "" : String(row.weight),
501
  club: row.club ?? "", position: row.position ?? "", gender: row.gender ?? null,
 
579
  playerId: player.player_id, fname: player.fname ?? "", lname: player.lname ?? "", name: [player.fname, player.lname].filter(Boolean).join(" "),
580
  isOwner: player.user_id === current.user.id,
581
  age: ageFromBirthday(player.bdate),
582
+ bdate: player.bdate,
583
  heightCm: player.height == null ? "" : String(player.height), weightKg: player.weight == null ? "" : String(player.weight),
584
  club: player.club ?? "", position: player.position ?? "", gender: player.gender ?? null,
585
  imagePath: player.image_path ?? null,