Spaces:
Running
Running
Require complete player profile details
Browse files- src/admin.ts +24 -20
- 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 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 202 |
p_fname: input.data.fname,
|
| 203 |
-
p_lname: input.data.lname,
|
| 204 |
-
p_bdate:
|
| 205 |
-
p_height: input.data.heightCm
|
| 206 |
-
p_weight: input.data.weightKg
|
| 207 |
p_club: input.data.club || null,
|
| 208 |
-
p_position: input.data.position
|
| 209 |
-
p_gender: input.data.gender
|
| 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
|
| 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 |
-
|
| 79 |
-
heightCm: z.string().regex(/^
|
| 80 |
-
weightKg: z.string().regex(/^
|
| 81 |
club: z.string().trim().max(150),
|
| 82 |
-
position: z.string().trim().max(50),
|
| 83 |
-
gender: z.
|
| 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 |
-
|
| 94 |
-
heightCm: z.string().regex(/^
|
| 95 |
-
weightKg: z.string().regex(/^
|
| 96 |
club: z.string().trim().max(150),
|
| 97 |
-
position: z.string().trim().max(50),
|
| 98 |
-
gender: z.
|
| 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
|
| 319 |
-
const
|
| 320 |
-
|
| 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:
|
| 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,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
|
| 369 |
-
const
|
| 370 |
-
|
| 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
|
| 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:
|
| 389 |
height,
|
| 390 |
weight,
|
| 391 |
club: input.club || null,
|
| 392 |
-
position: input.position
|
| 393 |
-
gender: input.gender
|
| 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,
|