From 813ee245f0a2dea9609d10a4ce851daa5d8dbd91 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Sun, 2 Aug 2026 16:08:32 +1200 Subject: [PATCH 01/28] feat: create badge's schema --- src/lib/schemas/badge.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/lib/schemas/badge.ts diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts new file mode 100644 index 0000000..3d21a56 --- /dev/null +++ b/src/lib/schemas/badge.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export enum BadgeType { + Event = "EVENT", + Achievement = "ACHIEVEMENT", + Special = "SPECIAL", +} + +export const badgeSchema = z.object({ + name: z.string(), + category: z.nativeEnum(BadgeType), + description: z.string(), + imageUrl: z.string(), + criteria: z.string(), + eventid: z.int(), // check wether this event id is present + isactive: z.boolean(), +}); From 8a67c4b120f63726401e2fde26bb2d4425df225a Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Sun, 2 Aug 2026 16:10:40 +1200 Subject: [PATCH 02/28] feat: export the badge schema --- src/lib/schemas/badge.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts index 3d21a56..78921dc 100644 --- a/src/lib/schemas/badge.ts +++ b/src/lib/schemas/badge.ts @@ -6,7 +6,7 @@ export enum BadgeType { Special = "SPECIAL", } -export const badgeSchema = z.object({ +const badgeSchema = z.object({ name: z.string(), category: z.nativeEnum(BadgeType), description: z.string(), @@ -15,3 +15,6 @@ export const badgeSchema = z.object({ eventid: z.int(), // check wether this event id is present isactive: z.boolean(), }); + +export type Badge = z.infer; +export { badgeSchema }; From f7807fef31ec6f15bbd662bcafd0c6e6f53ac5b7 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 14:50:43 +1200 Subject: [PATCH 03/28] feat: add create, update, delete and get function for badge --- src/services/badges/badges-service.tsx | 67 ++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/services/badges/badges-service.tsx diff --git a/src/services/badges/badges-service.tsx b/src/services/badges/badges-service.tsx new file mode 100644 index 0000000..60a68f4 --- /dev/null +++ b/src/services/badges/badges-service.tsx @@ -0,0 +1,67 @@ +import { Badge } from "../../lib/schemas/badge"; +import { getSupabase } from "../supabase"; + +export default class BadgesService { + /** + * Creates a new {@link Badge} in the database. + * @param badge The badge to create. + * @returns The created badge. + */ + public async createBadge(badge: Badge): Promise { + const supabase = await getSupabase(); + const { data, error } = await supabase + .from("badges") + .insert([badge]) + .select(); + if (error) throw error; + return data[0]; + } + + /** + * Retrieves a {@link Badge} by its ID. + * @param id The ID of the badge to retrieve. + * @returns The badge with the specified ID. + */ + public async getBadgeById(id: string): Promise { + const supabase = await getSupabase(); + const { data, error } = await supabase.from("badges").select().eq("id", id); + if (error) throw error; + return data[0]; + } + + /** + * Retrieves all {@link Badge}s from the database. + * @returns An array of all badges. + */ + public async getAllBadges(): Promise { + const supabase = await getSupabase(); + const { data, error } = await supabase.from("badges").select(); + if (error) throw error; + return data; + } + + /** + * Retrieves all {@link Badge}s by their name. + * @param name The name of the badges to retrieve. + * @returns An array of {@link Badge} with the specified name. + */ + public async getAllBadgesByName(name: string): Promise { + const supabase = await getSupabase(); + const { data, error } = await supabase + .from("badges") + .select() + .eq("name", name); + if (error) throw error; + return data; + } + + /** + * Deletes a {@link Badge} by its ID. + * @param id The ID of the badge to delete. + */ + public async deleteBadgeById(id: string): Promise { + const supabase = await getSupabase(); + const { error } = await supabase.from("badges").delete().eq("id", id); + if (error) throw error; + } +} From 5c888bad249ea19308735f8154f4d39a0595b395 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 15:09:33 +1200 Subject: [PATCH 04/28] feat: remove await for getSupabase error and add update badge function --- src/services/badges/badges-service.tsx | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/services/badges/badges-service.tsx b/src/services/badges/badges-service.tsx index 60a68f4..fd9f99a 100644 --- a/src/services/badges/badges-service.tsx +++ b/src/services/badges/badges-service.tsx @@ -8,7 +8,7 @@ export default class BadgesService { * @returns The created badge. */ public async createBadge(badge: Badge): Promise { - const supabase = await getSupabase(); + const supabase = getSupabase(); const { data, error } = await supabase .from("badges") .insert([badge]) @@ -23,7 +23,7 @@ export default class BadgesService { * @returns The badge with the specified ID. */ public async getBadgeById(id: string): Promise { - const supabase = await getSupabase(); + const supabase = getSupabase(); const { data, error } = await supabase.from("badges").select().eq("id", id); if (error) throw error; return data[0]; @@ -34,7 +34,7 @@ export default class BadgesService { * @returns An array of all badges. */ public async getAllBadges(): Promise { - const supabase = await getSupabase(); + const supabase = getSupabase(); const { data, error } = await supabase.from("badges").select(); if (error) throw error; return data; @@ -46,7 +46,7 @@ export default class BadgesService { * @returns An array of {@link Badge} with the specified name. */ public async getAllBadgesByName(name: string): Promise { - const supabase = await getSupabase(); + const supabase = getSupabase(); const { data, error } = await supabase .from("badges") .select() @@ -55,12 +55,29 @@ export default class BadgesService { return data; } + /** + * Updates a {@link Badge} by its ID. + * @param id The ID of the badge to update. + * @param badge The updated badge data. + * @returns The updated {@link Badge}. + */ + public async updateBadgesById(id: string, badge: Badge): Promise { + const supabase = getSupabase(); + const { data, error } = await supabase + .from("badges") + .update(badge) + .eq("id", id) + .select(); + if (error) throw error; + return data[0]; + } + /** * Deletes a {@link Badge} by its ID. * @param id The ID of the badge to delete. */ public async deleteBadgeById(id: string): Promise { - const supabase = await getSupabase(); + const supabase = getSupabase(); const { error } = await supabase.from("badges").delete().eq("id", id); if (error) throw error; } From e90748b88d6bbe3ed9c2a8e8a8937531bc97dd8e Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 16:43:34 +1200 Subject: [PATCH 05/28] feat: add more constrains on the badge data model --- src/lib/schemas/badge.ts | 42 ++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts index 78921dc..57d4d8b 100644 --- a/src/lib/schemas/badge.ts +++ b/src/lib/schemas/badge.ts @@ -1,20 +1,46 @@ import { z } from "zod"; +/** Values of the `badge_enum` Postgres type. */ export enum BadgeType { Event = "EVENT", Achievement = "ACHIEVEMENT", Special = "SPECIAL", } -const badgeSchema = z.object({ - name: z.string(), +// Field names match the Supabase column names exactly (`imageurl`, `eventid`, +// `isactive`). PostgREST matches JSON keys to columns case-sensitively, so a +// camelCase `imageUrl` is rejected as an unknown column. +const badgeFieldsSchema = z.object({ + name: z.string().trim().min(1, "Badge name is required"), category: z.nativeEnum(BadgeType), - description: z.string(), - imageUrl: z.string(), - criteria: z.string(), - eventid: z.int(), // check wether this event id is present - isactive: z.boolean(), + description: z.string().trim().nullable().default(null), + imageurl: z.string().trim().nullable().default(null), + criteria: z.string().trim().nullable().default(null), + eventid: z.uuid().nullable().default(null), + isactive: z.boolean().default(true), }); +// An EVENT badge is meaningless without the event it belongs to. Updates can't +// check this rule in isolation — a patch may change only `category` while +// `eventid` is already set on the row — so the authoritative check lives in the +// `badges_event_requires_eventid` constraint. This is just the early feedback. +const badgeInsertSchema = badgeFieldsSchema.refine( + (badge) => badge.category !== BadgeType.Event || badge.eventid !== null, + { path: ["eventid"], message: "An EVENT badge must reference an event" }, +); + +const badgeUpdateSchema = badgeFieldsSchema.partial(); + +const badgeSchema = badgeFieldsSchema.extend({ + id: z.uuid(), + created_at: z.string(), +}); + +/** A badge row as it comes back from the database. */ export type Badge = z.infer; -export { badgeSchema }; +/** The payload accepted by `createBadge` — defaulted fields may be omitted. */ +export type BadgeInsert = z.input; +/** A partial patch accepted by `updateBadgeById`. */ +export type BadgeUpdate = z.input; + +export { badgeSchema, badgeInsertSchema, badgeUpdateSchema }; From 2fe61e0532d4a323209fdeface4b54daba538b11 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 16:49:43 +1200 Subject: [PATCH 06/28] feat: update the functions to project style --- src/services/badges/badges-service.tsx | 121 +++++++++++++++++-------- 1 file changed, 83 insertions(+), 38 deletions(-) diff --git a/src/services/badges/badges-service.tsx b/src/services/badges/badges-service.tsx index fd9f99a..7f568ae 100644 --- a/src/services/badges/badges-service.tsx +++ b/src/services/badges/badges-service.tsx @@ -1,84 +1,129 @@ -import { Badge } from "../../lib/schemas/badge"; +import { SupabaseClient } from "@supabase/supabase-js"; + +import { + Badge, + BadgeInsert, + BadgeUpdate, + badgeInsertSchema, + badgeUpdateSchema, +} from "../../lib/schemas/badge"; import { getSupabase } from "../supabase"; +const TABLE = "badges"; + +/** Drops keys the caller left undefined so a patch never clears a column by accident. */ +function definedFields(patch: T): Partial { + return Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== undefined), + ) as Partial; +} + export default class BadgesService { + /** + * @param client Supabase client to use. Defaults to the shared anon client. + * Injecting one lets unit tests pass a stub, and lets the integration tests + * pass a service-role client that can write past RLS. + */ + constructor(private readonly client?: SupabaseClient) {} + + private get db(): SupabaseClient { + return this.client ?? getSupabase(); + } + /** * Creates a new {@link Badge} in the database. - * @param badge The badge to create. + * @param badge The badge to create. `id` and `created_at` are set by the database. * @returns The created badge. */ - public async createBadge(badge: Badge): Promise { - const supabase = getSupabase(); - const { data, error } = await supabase - .from("badges") - .insert([badge]) - .select(); + public async createBadge(badge: BadgeInsert): Promise { + const payload = badgeInsertSchema.parse(badge); + + const { data, error } = await this.db + .from(TABLE) + .insert(payload) + .select() + .single(); + if (error) throw error; - return data[0]; + return data as Badge; } /** * Retrieves a {@link Badge} by its ID. * @param id The ID of the badge to retrieve. - * @returns The badge with the specified ID. + * @returns The matching badge, or `null` if no badge has that ID. */ - public async getBadgeById(id: string): Promise { - const supabase = getSupabase(); - const { data, error } = await supabase.from("badges").select().eq("id", id); + public async getBadgeById(id: string): Promise { + const { data, error } = await this.db + .from(TABLE) + .select() + .eq("id", id) + .maybeSingle(); + if (error) throw error; - return data[0]; + return data as Badge | null; } /** * Retrieves all {@link Badge}s from the database. - * @returns An array of all badges. + * @returns An array of all badges, newest first. */ public async getAllBadges(): Promise { - const supabase = getSupabase(); - const { data, error } = await supabase.from("badges").select(); + const { data, error } = await this.db + .from(TABLE) + .select() + .order("created_at", { ascending: false }); + if (error) throw error; - return data; + return (data ?? []) as Badge[]; } /** - * Retrieves all {@link Badge}s by their name. - * @param name The name of the badges to retrieve. - * @returns An array of {@link Badge} with the specified name. + * Retrieves all {@link Badge}s with the given name. + * @param name The exact name to match. + * @returns An array of badges with that name. */ public async getAllBadgesByName(name: string): Promise { - const supabase = getSupabase(); - const { data, error } = await supabase - .from("badges") + const { data, error } = await this.db + .from(TABLE) .select() - .eq("name", name); + .eq("name", name.trim()); + if (error) throw error; - return data; + return (data ?? []) as Badge[]; } /** * Updates a {@link Badge} by its ID. * @param id The ID of the badge to update. - * @param badge The updated badge data. - * @returns The updated {@link Badge}. + * @param patch The fields to change. Omitted fields are left untouched. + * @returns The updated badge. */ - public async updateBadgesById(id: string, badge: Badge): Promise { - const supabase = getSupabase(); - const { data, error } = await supabase - .from("badges") - .update(badge) + public async updateBadgeById(id: string, patch: BadgeUpdate): Promise { + const payload = definedFields(badgeUpdateSchema.parse(patch)); + + if (Object.keys(payload).length === 0) { + throw new Error("updateBadgeById was called with no fields to update"); + } + + const { data, error } = await this.db + .from(TABLE) + .update(payload) .eq("id", id) - .select(); + .select() + .single(); + if (error) throw error; - return data[0]; + return data as Badge; } /** - * Deletes a {@link Badge} by its ID. + * Deletes a {@link Badge} by its ID. Any member_badges rows referencing it + * are removed too, via the foreign key's ON DELETE CASCADE. * @param id The ID of the badge to delete. */ public async deleteBadgeById(id: string): Promise { - const supabase = getSupabase(); - const { error } = await supabase.from("badges").delete().eq("id", id); + const { error } = await this.db.from(TABLE).delete().eq("id", id); if (error) throw error; } } From f075c12108367198ca6e2af8abb1e0b7efd496a5 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 16:50:18 +1200 Subject: [PATCH 07/28] feat: create member badge data model --- src/lib/schemas/member-badge.ts | 39 +++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/lib/schemas/member-badge.ts diff --git a/src/lib/schemas/member-badge.ts b/src/lib/schemas/member-badge.ts new file mode 100644 index 0000000..0cf2015 --- /dev/null +++ b/src/lib/schemas/member-badge.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { badgeSchema } from "./badge"; + +// As with `badge.ts`, field names mirror the `member_badges` columns verbatim. +// memberid is a uuid referencing public_profiles(id), via the existing +// member_badges_memberid_fkey. Note it is *not* registrations.id, which is a +// bigint — these are two different notions of "member". +const memberBadgeFieldsSchema = z.object({ + badgeid: z.uuid(), + memberid: z.uuid(), + attendanceid: z.uuid().nullable().default(null), +}); + +const memberBadgeInsertSchema = memberBadgeFieldsSchema; + +const memberBadgeSchema = memberBadgeFieldsSchema.extend({ + id: z.uuid(), + awardedat: z.string(), +}); + +// Returned by the queries that embed the badge via PostgREST's foreign-key +// join, so callers get the badge's name and image without a second round trip. +const memberBadgeWithBadgeSchema = memberBadgeSchema.extend({ + badge: badgeSchema, +}); + +/** A member_badges row as it comes back from the database. */ +export type MemberBadge = z.infer; +/** A member_badges row with its parent badge embedded. */ +export type MemberBadgeWithBadge = z.infer; +/** The payload accepted by `awardBadge`. */ +export type MemberBadgeInsert = z.input; + +export { + memberBadgeSchema, + memberBadgeInsertSchema, + memberBadgeWithBadgeSchema, +}; From 1aa70f7bd16e43b92f098eb09dc4c62e55a9785c Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 16:50:31 +1200 Subject: [PATCH 08/28] feat: create member badge service --- .../member-badges/member-badges-service.tsx | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/services/member-badges/member-badges-service.tsx diff --git a/src/services/member-badges/member-badges-service.tsx b/src/services/member-badges/member-badges-service.tsx new file mode 100644 index 0000000..2c2a654 --- /dev/null +++ b/src/services/member-badges/member-badges-service.tsx @@ -0,0 +1,137 @@ +import { SupabaseClient } from "@supabase/supabase-js"; + +import { + MemberBadge, + MemberBadgeInsert, + MemberBadgeWithBadge, + memberBadgeInsertSchema, +} from "../../lib/schemas/member-badge"; +import { getSupabase } from "../supabase"; + +const TABLE = "member_badges"; + +// Raised by Postgres when the unique(badgeid, memberid) constraint is violated. +const UNIQUE_VIOLATION = "23505"; + +// Pulls the parent badge in with the join row, via the badgeid foreign key. +const WITH_BADGE = "*, badge:badges(*)"; + +export default class MemberBadgesService { + /** + * @param client Supabase client to use. Defaults to the shared anon client. + * @see BadgesService for why this is injectable. + */ + constructor(private readonly client?: SupabaseClient) {} + + private get db(): SupabaseClient { + return this.client ?? getSupabase(); + } + + /** + * Awards a badge to a member. Idempotent: awarding a badge the member + * already holds returns the existing row rather than throwing. + * @param award The badge, member, and optionally the attendance that earned it. + * @returns The member_badges row. + */ + public async awardBadge(award: MemberBadgeInsert): Promise { + const payload = memberBadgeInsertSchema.parse(award); + + const { data, error } = await this.db + .from(TABLE) + .insert(payload) + .select() + .single(); + + if (error) { + if (error.code === UNIQUE_VIOLATION) { + const existing = await this.getAward(payload.badgeid, payload.memberid); + if (existing) return existing; + } + throw error; + } + + return data as MemberBadge; + } + + /** + * Retrieves the award linking a badge to a member. + * @returns The member_badges row, or `null` if the member doesn't hold the badge. + */ + public async getAward( + badgeId: string, + memberId: string, + ): Promise { + const { data, error } = await this.db + .from(TABLE) + .select() + .eq("badgeid", badgeId) + .eq("memberid", memberId) + .maybeSingle(); + + if (error) throw error; + return data as MemberBadge | null; + } + + /** + * Checks whether a member holds a badge. + */ + public async hasBadge(badgeId: string, memberId: string): Promise { + return (await this.getAward(badgeId, memberId)) !== null; + } + + /** + * Retrieves every badge a member holds, with the badge details embedded. + * @param memberId The public_profiles.id of the member. + */ + public async getBadgesForMember( + memberId: string, + ): Promise { + const { data, error } = await this.db + .from(TABLE) + .select(WITH_BADGE) + .eq("memberid", memberId) + .order("awardedat", { ascending: false }); + + if (error) throw error; + return (data ?? []) as unknown as MemberBadgeWithBadge[]; + } + + /** + * Retrieves every award of a given badge, i.e. everyone who holds it. + */ + public async getMembersWithBadge(badgeId: string): Promise { + const { data, error } = await this.db + .from(TABLE) + .select() + .eq("badgeid", badgeId) + .order("awardedat", { ascending: false }); + + if (error) throw error; + return (data ?? []) as MemberBadge[]; + } + + /** + * Takes a badge away from a member by deleting the join row. Deleting a badge + * the member doesn't hold is a no-op. + * + * This is a hard delete — the table's `revokedat`/`revokedby` columns are + * deliberately unused until the admin model exists to populate them. + */ + public async revokeBadge(badgeId: string, memberId: string): Promise { + const { error } = await this.db + .from(TABLE) + .delete() + .eq("badgeid", badgeId) + .eq("memberid", memberId); + + if (error) throw error; + } + + /** + * Deletes a member_badges row by its own ID. + */ + public async revokeAwardById(id: string): Promise { + const { error } = await this.db.from(TABLE).delete().eq("id", id); + if (error) throw error; + } +} From e6eb412bebe88aa6ef181533ec9eb14fb23c7dad Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 17:58:27 +1200 Subject: [PATCH 09/28] fix: adapt partial() function, default re-write the original value --- src/lib/schemas/badge.ts | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts index 57d4d8b..b43bced 100644 --- a/src/lib/schemas/badge.ts +++ b/src/lib/schemas/badge.ts @@ -13,21 +13,33 @@ export enum BadgeType { const badgeFieldsSchema = z.object({ name: z.string().trim().min(1, "Badge name is required"), category: z.nativeEnum(BadgeType), - description: z.string().trim().nullable().default(null), - imageurl: z.string().trim().nullable().default(null), - criteria: z.string().trim().nullable().default(null), - eventid: z.uuid().nullable().default(null), - isactive: z.boolean().default(true), + description: z.string().trim().nullable(), + imageurl: z.string().trim().nullable(), + criteria: z.string().trim().nullable(), + eventid: z.uuid().nullable(), + isactive: z.boolean(), }); -// An EVENT badge is meaningless without the event it belongs to. Updates can't -// check this rule in isolation — a patch may change only `category` while -// `eventid` is already set on the row — so the authoritative check lives in the -// `badges_event_requires_eventid` constraint. This is just the early feedback. -const badgeInsertSchema = badgeFieldsSchema.refine( - (badge) => badge.category !== BadgeType.Event || badge.eventid !== null, - { path: ["eventid"], message: "An EVENT badge must reference an event" }, -); +// Defaults are layered on here rather than declared on the shared field schema, +// because `.partial()` makes a key optional but still applies its default — so +// a default on `badgeFieldsSchema` would leak into `badgeUpdateSchema` and make +// every patch silently overwrite the columns the caller never mentioned. +// +// The EVENT rule is early feedback only. A partial update can't check it in +// isolation, since a patch may change `category` alone while `eventid` is +// already set on the row, so the database constraint is the real guarantee. +const badgeInsertSchema = badgeFieldsSchema + .extend({ + description: badgeFieldsSchema.shape.description.default(null), + imageurl: badgeFieldsSchema.shape.imageurl.default(null), + criteria: badgeFieldsSchema.shape.criteria.default(null), + eventid: badgeFieldsSchema.shape.eventid.default(null), + isactive: badgeFieldsSchema.shape.isactive.default(true), + }) + .refine( + (badge) => badge.category !== BadgeType.Event || badge.eventid !== null, + { path: ["eventid"], message: "An EVENT badge must reference an event" }, + ); const badgeUpdateSchema = badgeFieldsSchema.partial(); From 93fecb81bd17b3288d489b541eabc77dfa6a947d Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Mon, 3 Aug 2026 17:58:46 +1200 Subject: [PATCH 10/28] feat: create uniting for badges and member badges service --- src/services/badges/badges-service.test.ts | 341 +++++++++++++++ .../member-badges-service.test.ts | 402 ++++++++++++++++++ 2 files changed, 743 insertions(+) create mode 100644 src/services/badges/badges-service.test.ts create mode 100644 src/services/member-badges/member-badges-service.test.ts diff --git a/src/services/badges/badges-service.test.ts b/src/services/badges/badges-service.test.ts new file mode 100644 index 0000000..e49ff15 --- /dev/null +++ b/src/services/badges/badges-service.test.ts @@ -0,0 +1,341 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { describe, expect, it, vi } from "vitest"; + +import { Badge, BadgeType } from "../../lib/schemas/badge"; +import BadgesService from "./badges-service"; + +const BADGE_ID = "3f7c1e2a-1b4d-4c6f-9a2e-8d5b0c7f1a33"; +const EVENT_ID = "ed6707c8-232b-403e-ac17-6e64eef72790"; + +type MockFn = ReturnType; + +interface QueryResult { + data: unknown; + error: unknown; +} + +interface Builder { + select: MockFn; + insert: MockFn; + update: MockFn; + delete: MockFn; + eq: MockFn; + order: MockFn; + single: MockFn; + maybeSingle: MockFn; + then: PromiseLike["then"]; +} + +/** + * A stand-in for the Supabase client that records the query it was asked to + * build and resolves to a canned result. Pass an array to serve one result per + * query; the last one repeats once the queue runs dry. + * + * Real PostgREST builders are thenable, which is why `await from(x).select()` + * resolves without a terminal `.single()` — hence the `then` below. + */ +function fakeSupabase(results: QueryResult | QueryResult[]) { + const queue = Array.isArray(results) ? [...results] : [results]; + const next = (): QueryResult => (queue.length > 1 ? queue.shift()! : queue[0]); + + const chainable = (): MockFn => vi.fn(() => builder); + + const builder: Builder = { + select: chainable(), + insert: chainable(), + update: chainable(), + delete: chainable(), + eq: chainable(), + order: chainable(), + single: vi.fn(() => Promise.resolve(next())), + maybeSingle: vi.fn(() => Promise.resolve(next())), + then: (onFulfilled, onRejected) => + Promise.resolve(next()).then(onFulfilled, onRejected), + }; + + const from = vi.fn(() => builder); + + return { client: { from } as unknown as SupabaseClient, from, builder }; +} + +/** A Postgres error shaped the way supabase-js surfaces it. */ +function dbError(code: string, message = "stubbed failure") { + return { code, message, details: null, hint: null }; +} + +function badgeRow(overrides: Partial = {}): Badge { + return { + id: BADGE_ID, + created_at: "2026-08-03T00:00:00+00:00", + name: "Testing Badge", + category: BadgeType.Achievement, + description: "Awarded for shipping the badge system", + imageurl: null, + criteria: "Our code works fine", + eventid: null, + isactive: true, + ...overrides, + }; +} + +describe("BadgesService", () => { + describe("createBadge", () => { + it("inserts into the badges table and returns the created row", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + const created = await new BadgesService(fake.client).createBadge({ + name: "Testing Badge", + category: BadgeType.Achievement, + criteria: "Our code works fine", + }); + + expect(fake.from).toHaveBeenCalledWith("badges"); + expect(created.id).toBe(BADGE_ID); + }); + + it("fills in defaults for the optional fields the caller omitted", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + await new BadgesService(fake.client).createBadge({ + name: "Testing Badge", + category: BadgeType.Achievement, + }); + + expect(fake.builder.insert).toHaveBeenCalledWith({ + name: "Testing Badge", + category: BadgeType.Achievement, + description: null, + imageurl: null, + criteria: null, + eventid: null, + isactive: true, + }); + }); + + it("never sends an id or created_at — the database generates those", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + await new BadgesService(fake.client).createBadge({ + name: "Testing Badge", + category: BadgeType.Achievement, + }); + + const [payload] = fake.builder.insert.mock.calls[0]; + expect(payload).not.toHaveProperty("id"); + expect(payload).not.toHaveProperty("created_at"); + }); + + it("trims the badge name", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + await new BadgesService(fake.client).createBadge({ + name: " Testing Badge ", + category: BadgeType.Achievement, + }); + + const [payload] = fake.builder.insert.mock.calls[0]; + expect(payload.name).toBe("Testing Badge"); + }); + + it("rejects a blank name without touching the database", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new BadgesService(fake.client).createBadge({ + name: " ", + category: BadgeType.Achievement, + }), + ).rejects.toThrow(/Badge name is required/); + + expect(fake.from).not.toHaveBeenCalled(); + }); + + it("rejects an EVENT badge that references no event", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new BadgesService(fake.client).createBadge({ + name: "Hackathon 2026", + category: BadgeType.Event, + }), + ).rejects.toThrow(/must reference an event/); + + expect(fake.from).not.toHaveBeenCalled(); + }); + + it("accepts an EVENT badge that does reference an event", async () => { + const fake = fakeSupabase({ + data: badgeRow({ category: BadgeType.Event, eventid: EVENT_ID }), + error: null, + }); + + const created = await new BadgesService(fake.client).createBadge({ + name: "Hackathon 2026", + category: BadgeType.Event, + eventid: EVENT_ID, + }); + + expect(created.eventid).toBe(EVENT_ID); + }); + + it("throws the database error when the insert fails", async () => { + const fake = fakeSupabase({ + data: null, + error: dbError("42501", "row-level security policy"), + }); + + await expect( + new BadgesService(fake.client).createBadge({ + name: "Testing Badge", + category: BadgeType.Achievement, + }), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); + + describe("getBadgeById", () => { + it("filters on the id and returns the badge", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + const badge = await new BadgesService(fake.client).getBadgeById(BADGE_ID); + + expect(fake.builder.eq).toHaveBeenCalledWith("id", BADGE_ID); + expect(badge?.name).toBe("Testing Badge"); + }); + + it("returns null when no badge has that id", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + const badge = await new BadgesService(fake.client).getBadgeById(BADGE_ID); + + expect(badge).toBeNull(); + }); + }); + + describe("getAllBadges", () => { + it("returns every row", async () => { + const fake = fakeSupabase({ + data: [badgeRow(), badgeRow({ id: EVENT_ID, name: "Second" })], + error: null, + }); + + const badges = await new BadgesService(fake.client).getAllBadges(); + + expect(badges).toHaveLength(2); + }); + + it("returns an empty array rather than null when there are none", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new BadgesService(fake.client).getAllBadges(), + ).resolves.toEqual([]); + }); + }); + + describe("getAllBadgesByName", () => { + it("trims the name before matching", async () => { + const fake = fakeSupabase({ data: [badgeRow()], error: null }); + + await new BadgesService(fake.client).getAllBadgesByName( + " Testing Badge ", + ); + + expect(fake.builder.eq).toHaveBeenCalledWith("name", "Testing Badge"); + }); + + it("returns the badges that matched", async () => { + const fake = fakeSupabase({ data: [badgeRow()], error: null }); + + const badges = await new BadgesService(fake.client).getAllBadgesByName( + "Testing Badge", + ); + + expect(badges).toHaveLength(1); + expect(badges[0].id).toBe(BADGE_ID); + }); + }); + + describe("updateBadgeById", () => { + it("sends only the fields the caller supplied", async () => { + const fake = fakeSupabase({ + data: badgeRow({ name: "Updated Testing Badge" }), + error: null, + }); + + const updated = await new BadgesService(fake.client).updateBadgeById( + BADGE_ID, + { name: "Updated Testing Badge" }, + ); + + expect(fake.builder.update).toHaveBeenCalledWith({ + name: "Updated Testing Badge", + }); + expect(fake.builder.eq).toHaveBeenCalledWith("id", BADGE_ID); + expect(updated.name).toBe("Updated Testing Badge"); + }); + + it("drops keys explicitly set to undefined", async () => { + const fake = fakeSupabase({ data: badgeRow(), error: null }); + + await new BadgesService(fake.client).updateBadgeById(BADGE_ID, { + name: "Still Here", + description: undefined, + }); + + expect(fake.builder.update).toHaveBeenCalledWith({ name: "Still Here" }); + }); + + it("can null out an optional field", async () => { + const fake = fakeSupabase({ + data: badgeRow({ criteria: null }), + error: null, + }); + + await new BadgesService(fake.client).updateBadgeById(BADGE_ID, { + criteria: null, + }); + + expect(fake.builder.update).toHaveBeenCalledWith({ criteria: null }); + }); + + it("refuses an empty patch instead of issuing a no-op query", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new BadgesService(fake.client).updateBadgeById(BADGE_ID, {}), + ).rejects.toThrow(/no fields to update/); + + expect(fake.from).not.toHaveBeenCalled(); + }); + + it("throws the database error when the update fails", async () => { + const fake = fakeSupabase({ data: null, error: dbError("42501") }); + + await expect( + new BadgesService(fake.client).updateBadgeById(BADGE_ID, { + name: "Nope", + }), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); + + describe("deleteBadgeById", () => { + it("deletes the row matching the id", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await new BadgesService(fake.client).deleteBadgeById(BADGE_ID); + + expect(fake.builder.delete).toHaveBeenCalled(); + expect(fake.builder.eq).toHaveBeenCalledWith("id", BADGE_ID); + }); + + it("throws the database error when the delete fails", async () => { + const fake = fakeSupabase({ data: null, error: dbError("42501") }); + + await expect( + new BadgesService(fake.client).deleteBadgeById(BADGE_ID), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); +}); diff --git a/src/services/member-badges/member-badges-service.test.ts b/src/services/member-badges/member-badges-service.test.ts new file mode 100644 index 0000000..7760e28 --- /dev/null +++ b/src/services/member-badges/member-badges-service.test.ts @@ -0,0 +1,402 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { describe, expect, it, vi } from "vitest"; + +import { Badge, BadgeType } from "../../lib/schemas/badge"; +import { MemberBadge } from "../../lib/schemas/member-badge"; +import MemberBadgesService from "./member-badges-service"; + +const AWARD_ID = "9c1d7b3e-5a2f-4e18-b7d0-2c6a4f9e8b11"; +const BADGE_ID = "3f7c1e2a-1b4d-4c6f-9a2e-8d5b0c7f1a33"; +const MEMBER_ID = "5e8a2c4b-6d31-4f97-a02e-1b7c9d4e6f85"; +const ATTENDANCE_ID = "7b2e4d19-3c8a-4f52-9e61-0a3d5c8b7f24"; + +type MockFn = ReturnType; + +interface QueryResult { + data: unknown; + error: unknown; +} + +interface Builder { + select: MockFn; + insert: MockFn; + update: MockFn; + delete: MockFn; + eq: MockFn; + order: MockFn; + single: MockFn; + maybeSingle: MockFn; + then: PromiseLike["then"]; +} + +/** + * A stand-in for the Supabase client that records the query it was asked to + * build and resolves to a canned result. Pass an array to serve one result per + * query; the last one repeats once the queue runs dry, which is what the + * duplicate-award path needs since it issues a second query after the conflict. + * + * Real PostgREST builders are thenable, which is why `await from(x).select()` + * resolves without a terminal `.single()` — hence the `then` below. + */ +function fakeSupabase(results: QueryResult | QueryResult[]) { + const queue = Array.isArray(results) ? [...results] : [results]; + const next = (): QueryResult => (queue.length > 1 ? queue.shift()! : queue[0]); + + const chainable = (): MockFn => vi.fn(() => builder); + + const builder: Builder = { + select: chainable(), + insert: chainable(), + update: chainable(), + delete: chainable(), + eq: chainable(), + order: chainable(), + single: vi.fn(() => Promise.resolve(next())), + maybeSingle: vi.fn(() => Promise.resolve(next())), + then: (onFulfilled, onRejected) => + Promise.resolve(next()).then(onFulfilled, onRejected), + }; + + const from = vi.fn(() => builder); + + return { client: { from } as unknown as SupabaseClient, from, builder }; +} + +/** A Postgres error shaped the way supabase-js surfaces it. */ +function dbError(code: string, message = "stubbed failure") { + return { code, message, details: null, hint: null }; +} + +function awardRow(overrides: Partial = {}): MemberBadge { + return { + id: AWARD_ID, + awardedat: "2026-08-03T00:00:00+00:00", + badgeid: BADGE_ID, + memberid: MEMBER_ID, + attendanceid: null, + ...overrides, + }; +} + +function badgeRow(overrides: Partial = {}): Badge { + return { + id: BADGE_ID, + created_at: "2026-08-03T00:00:00+00:00", + name: "Testing Badge", + category: BadgeType.Achievement, + description: "Awarded for shipping the badge system", + imageurl: null, + criteria: "Our code works fine", + eventid: null, + isactive: true, + ...overrides, + }; +} + +describe("MemberBadgesService", () => { + describe("awardBadge", () => { + it("inserts into the member_badges table and returns the award", async () => { + const fake = fakeSupabase({ data: awardRow(), error: null }); + + const award = await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }); + + expect(fake.from).toHaveBeenCalledWith("member_badges"); + expect(award.id).toBe(AWARD_ID); + }); + + it("defaults attendanceid to null when the caller omits it", async () => { + const fake = fakeSupabase({ data: awardRow(), error: null }); + + await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }); + + expect(fake.builder.insert).toHaveBeenCalledWith({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + attendanceid: null, + }); + }); + + it("passes an attendance id through when one is given", async () => { + const fake = fakeSupabase({ + data: awardRow({ attendanceid: ATTENDANCE_ID }), + error: null, + }); + + const award = await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + attendanceid: ATTENDANCE_ID, + }); + + expect(fake.builder.insert).toHaveBeenCalledWith({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + attendanceid: ATTENDANCE_ID, + }); + expect(award.attendanceid).toBe(ATTENDANCE_ID); + }); + + it("never sends an id or awardedat — the database generates those", async () => { + const fake = fakeSupabase({ data: awardRow(), error: null }); + + await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }); + + const [payload] = fake.builder.insert.mock.calls[0]; + expect(payload).not.toHaveProperty("id"); + expect(payload).not.toHaveProperty("awardedat"); + }); + + it("rejects a badgeid that isn't a uuid without touching the database", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).awardBadge({ + badgeid: "not-a-uuid", + memberid: MEMBER_ID, + }), + ).rejects.toThrow(); + + expect(fake.from).not.toHaveBeenCalled(); + }); + + it("rejects a memberid passed as a registrations-style bigint", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + // memberid is a public_profiles uuid, not registrations.id — this is + // the confusion the schema guards against. + memberid: 99 as unknown as string, + }), + ).rejects.toThrow(); + + expect(fake.from).not.toHaveBeenCalled(); + }); + + it("is idempotent — a duplicate award returns the existing row", async () => { + const fake = fakeSupabase([ + { data: null, error: dbError("23505", "duplicate key value") }, + { data: awardRow(), error: null }, + ]); + + const award = await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }); + + expect(award.id).toBe(AWARD_ID); + }); + + it("looks the existing award up by badge and member after a conflict", async () => { + const fake = fakeSupabase([ + { data: null, error: dbError("23505") }, + { data: awardRow(), error: null }, + ]); + + await new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }); + + expect(fake.builder.eq).toHaveBeenCalledWith("badgeid", BADGE_ID); + expect(fake.builder.eq).toHaveBeenCalledWith("memberid", MEMBER_ID); + }); + + it("rethrows the conflict if the existing award can't be found", async () => { + const fake = fakeSupabase([ + { data: null, error: dbError("23505") }, + { data: null, error: null }, + ]); + + await expect( + new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }), + ).rejects.toMatchObject({ code: "23505" }); + }); + + it("rethrows errors that aren't unique violations", async () => { + const fake = fakeSupabase({ + data: null, + error: dbError("23503", "foreign key violation"), + }); + + await expect( + new MemberBadgesService(fake.client).awardBadge({ + badgeid: BADGE_ID, + memberid: MEMBER_ID, + }), + ).rejects.toMatchObject({ code: "23503" }); + }); + }); + + describe("getAward", () => { + it("filters on both the badge and the member", async () => { + const fake = fakeSupabase({ data: awardRow(), error: null }); + + const award = await new MemberBadgesService(fake.client).getAward( + BADGE_ID, + MEMBER_ID, + ); + + expect(fake.builder.eq).toHaveBeenCalledWith("badgeid", BADGE_ID); + expect(fake.builder.eq).toHaveBeenCalledWith("memberid", MEMBER_ID); + expect(award?.id).toBe(AWARD_ID); + }); + + it("returns null when the member doesn't hold the badge", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).getAward(BADGE_ID, MEMBER_ID), + ).resolves.toBeNull(); + }); + + it("throws the database error when the lookup fails", async () => { + const fake = fakeSupabase({ data: null, error: dbError("42501") }); + + await expect( + new MemberBadgesService(fake.client).getAward(BADGE_ID, MEMBER_ID), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); + + describe("hasBadge", () => { + it("is true when an award exists", async () => { + const fake = fakeSupabase({ data: awardRow(), error: null }); + + await expect( + new MemberBadgesService(fake.client).hasBadge(BADGE_ID, MEMBER_ID), + ).resolves.toBe(true); + }); + + it("is false when no award exists", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).hasBadge(BADGE_ID, MEMBER_ID), + ).resolves.toBe(false); + }); + }); + + describe("getBadgesForMember", () => { + it("embeds the badge, filters on the member and orders by award date", async () => { + const fake = fakeSupabase({ data: [], error: null }); + + await new MemberBadgesService(fake.client).getBadgesForMember(MEMBER_ID); + + expect(fake.builder.select).toHaveBeenCalledWith("*, badge:badges(*)"); + expect(fake.builder.eq).toHaveBeenCalledWith("memberid", MEMBER_ID); + expect(fake.builder.order).toHaveBeenCalledWith("awardedat", { + ascending: false, + }); + }); + + it("returns the awards with their badge attached", async () => { + const fake = fakeSupabase({ + data: [{ ...awardRow(), badge: badgeRow() }], + error: null, + }); + + const held = await new MemberBadgesService( + fake.client, + ).getBadgesForMember(MEMBER_ID); + + expect(held).toHaveLength(1); + expect(held[0].badge.name).toBe("Testing Badge"); + }); + + it("returns an empty array rather than null when the member holds none", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).getBadgesForMember(MEMBER_ID), + ).resolves.toEqual([]); + }); + }); + + describe("getMembersWithBadge", () => { + it("filters on the badge and orders by award date", async () => { + const fake = fakeSupabase({ data: [awardRow()], error: null }); + + const holders = await new MemberBadgesService( + fake.client, + ).getMembersWithBadge(BADGE_ID); + + expect(fake.builder.eq).toHaveBeenCalledWith("badgeid", BADGE_ID); + expect(fake.builder.order).toHaveBeenCalledWith("awardedat", { + ascending: false, + }); + expect(holders).toHaveLength(1); + }); + + it("returns an empty array rather than null when nobody holds it", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).getMembersWithBadge(BADGE_ID), + ).resolves.toEqual([]); + }); + }); + + describe("revokeBadge", () => { + it("deletes the row matching both the badge and the member", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await new MemberBadgesService(fake.client).revokeBadge( + BADGE_ID, + MEMBER_ID, + ); + + expect(fake.builder.delete).toHaveBeenCalled(); + expect(fake.builder.eq).toHaveBeenCalledWith("badgeid", BADGE_ID); + expect(fake.builder.eq).toHaveBeenCalledWith("memberid", MEMBER_ID); + }); + + it("resolves without error when the member doesn't hold the badge", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await expect( + new MemberBadgesService(fake.client).revokeBadge(BADGE_ID, MEMBER_ID), + ).resolves.toBeUndefined(); + }); + + it("throws the database error when the delete fails", async () => { + const fake = fakeSupabase({ data: null, error: dbError("42501") }); + + await expect( + new MemberBadgesService(fake.client).revokeBadge(BADGE_ID, MEMBER_ID), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); + + describe("revokeAwardById", () => { + it("deletes by the join row's own id", async () => { + const fake = fakeSupabase({ data: null, error: null }); + + await new MemberBadgesService(fake.client).revokeAwardById(AWARD_ID); + + expect(fake.builder.delete).toHaveBeenCalled(); + expect(fake.builder.eq).toHaveBeenCalledWith("id", AWARD_ID); + }); + + it("throws the database error when the delete fails", async () => { + const fake = fakeSupabase({ data: null, error: dbError("42501") }); + + await expect( + new MemberBadgesService(fake.client).revokeAwardById(AWARD_ID), + ).rejects.toMatchObject({ code: "42501" }); + }); + }); +}); From 64ccee2588c03714ffddd1cd3f47edb85dd68cde Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Fri, 18 Sep 2026 15:26:15 +1200 Subject: [PATCH 11/28] fix: change member badge linking registration id instead of public profile id --- src/lib/schemas/member-badge.ts | 9 +++++---- src/services/member-badges/member-badges-service.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/lib/schemas/member-badge.ts b/src/lib/schemas/member-badge.ts index 0cf2015..2cafe90 100644 --- a/src/lib/schemas/member-badge.ts +++ b/src/lib/schemas/member-badge.ts @@ -3,12 +3,13 @@ import { z } from "zod"; import { badgeSchema } from "./badge"; // As with `badge.ts`, field names mirror the `member_badges` columns verbatim. -// memberid is a uuid referencing public_profiles(id), via the existing -// member_badges_memberid_fkey. Note it is *not* registrations.id, which is a -// bigint — these are two different notions of "member". +// memberid is an int8 referencing registrations(id), via the existing +// member_badges_memberid_fkey. It is deliberately *not* public_profiles.id: +// that row is deleted when a member hides their profile, which would take +// their badges with it, whereas the registration outlives visibility changes. const memberBadgeFieldsSchema = z.object({ badgeid: z.uuid(), - memberid: z.uuid(), + memberid: z.number().int().positive(), attendanceid: z.uuid().nullable().default(null), }); diff --git a/src/services/member-badges/member-badges-service.tsx b/src/services/member-badges/member-badges-service.tsx index 2c2a654..1b0bafc 100644 --- a/src/services/member-badges/member-badges-service.tsx +++ b/src/services/member-badges/member-badges-service.tsx @@ -59,7 +59,7 @@ export default class MemberBadgesService { */ public async getAward( badgeId: string, - memberId: string, + memberId: number, ): Promise { const { data, error } = await this.db .from(TABLE) @@ -75,16 +75,16 @@ export default class MemberBadgesService { /** * Checks whether a member holds a badge. */ - public async hasBadge(badgeId: string, memberId: string): Promise { + public async hasBadge(badgeId: string, memberId: number): Promise { return (await this.getAward(badgeId, memberId)) !== null; } /** * Retrieves every badge a member holds, with the badge details embedded. - * @param memberId The public_profiles.id of the member. + * @param memberId The registrations.id of the member. */ public async getBadgesForMember( - memberId: string, + memberId: number, ): Promise { const { data, error } = await this.db .from(TABLE) @@ -117,7 +117,7 @@ export default class MemberBadgesService { * This is a hard delete — the table's `revokedat`/`revokedby` columns are * deliberately unused until the admin model exists to populate them. */ - public async revokeBadge(badgeId: string, memberId: string): Promise { + public async revokeBadge(badgeId: string, memberId: number): Promise { const { error } = await this.db .from(TABLE) .delete() From 883c2fff0e3fab351d7b60893c1f9d296ea1f0c6 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Fri, 18 Sep 2026 15:38:17 +1200 Subject: [PATCH 12/28] fix: add registration id in the public profile field --- src/services/public-profiles-service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/public-profiles-service.ts b/src/services/public-profiles-service.ts index 50f2f20..416b266 100644 --- a/src/services/public-profiles-service.ts +++ b/src/services/public-profiles-service.ts @@ -2,6 +2,7 @@ import { getSupabase } from "./supabase"; export type PublicProfile = { id: string; + registration_id: number; display_name: string; unique_name: string | null; profile_picture_url: string | null; @@ -14,13 +15,14 @@ export type PublicProfile = { export type PublicProfileSearchResult = Omit< PublicProfile, - "created_at" | "updated_at" + "created_at" | "updated_at" | "registration_id" > & { match_score: number; }; const PUBLIC_PROFILE_COLUMNS = [ "id", + "registration_id", "display_name", "unique_name", "profile_picture_url", From 2a64edf5bf15149eefd238daffe5ab335ae26e4c Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Wed, 23 Sep 2026 17:58:11 +1200 Subject: [PATCH 13/28] feat: add badge-card component to profile page, and use member-badge-service --- src/app/profile/[id]/page.tsx | 31 ++++++++---- src/app/profile/page.tsx | 38 +++++++++----- src/components/badge-card.tsx | 93 +++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 src/components/badge-card.tsx diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index 85d508b..c7235d4 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -6,10 +6,14 @@ import { PublicProfilesService, type PublicProfile, } from "@/services/public-profiles-service"; +import MemberBadgesService from "@/services/member-badges/member-badges-service"; +import type { MemberBadgeWithBadge } from "@/lib/schemas/member-badge"; +import { BadgeCard } from "@/components/badge-card"; export default function PublicProfilePage() { const { id } = useParams<{ id: string }>(); const [profile, setProfile] = useState(null); + const [badges, setBadges] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -30,6 +34,20 @@ export default function PublicProfilePage() { setError("This public profile could not be found."); } } + + // The route param is a public_profiles uuid, but member_badges is keyed + // on registrations.id, so the badges hang off registration_id. Fetched + // separately so a badge failure still leaves the profile readable. + if (publicProfile) { + try { + const awards = await new MemberBadgesService().getBadgesForMember( + publicProfile.registration_id, + ); + if (!cancelled) setBadges(awards); + } catch (badgeError) { + console.error("Unable to load badges", badgeError); + } + } } catch (profileError) { console.error("Unable to load public profile", profileError); @@ -113,17 +131,10 @@ export default function PublicProfilePage() {

Badges

- {profile.badges.length > 0 ? ( + {badges.length > 0 ? (
- {profile.badges.map((badge, index) => ( -
- - {badge} - -
+ {badges.map((award) => ( + ))}
) : ( diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index c39feda..4c7f0a5 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -11,6 +11,9 @@ import { ProfileVisibilityToggle } from "@/components/profile-visibility-toggle" import { IoQrCode } from "react-icons/io5"; import QrCode from "qrcode"; import { getSupabase } from "@/services/supabase"; +import MemberBadgesService from "@/services/member-badges/member-badges-service"; +import type { MemberBadgeWithBadge } from "@/lib/schemas/member-badge"; +import { BadgeCard } from "@/components/badge-card"; const getErrorMessage = (error: unknown) => { if (error instanceof Error) { @@ -50,7 +53,7 @@ export default function ProfilePage() { const [displayName, setDisplayName] = useState("Name"); const [isEditingName, setIsEditingName] = useState(false); const [profileImage, setProfileImage] = useState(null); - const [badges, setBadges] = useState([]); + const [badges, setBadges] = useState([]); const [eventsAttended, setEventsAttended] = useState([]); const [eventDetails, setEventDetails] = useState< Record @@ -180,7 +183,6 @@ export default function ProfilePage() { }); setProfileImage(result.profilePictureUrl); - setBadges(result.record.badges ?? []); setStatusMessage("Profile picture saved."); } catch (error) { const message = getErrorMessage(error); @@ -235,6 +237,24 @@ export default function ProfilePage() { } }; + // Badges live in member_badges, keyed on registrations.id. The query embeds + // the parent badge, so the image, description and criteria arrive in a + // single round trip and the hover panel needs no follow-up request. + // Failures are swallowed so a badge outage leaves the profile intact. + const fetchBadges = async (memberId: number) => { + try { + const awards = + await new MemberBadgesService().getBadgesForMember(memberId); + + if (!cancelled) { + setBadges(awards); + } + } catch (error) { + console.error("Failed to load badges", error); + if (!cancelled) setBadges([]); + } + }; + const loadProfile = async () => { if (!address) { setWalletRegistered(false); @@ -283,8 +303,8 @@ export default function ProfilePage() { const attended = registration.events_attended || []; setDisplayName(displayNameFallback || "Name"); setProfileImage(registration.profile_picture_url || null); - setBadges(registration.badges || []); setEventsAttended(attended); + await fetchBadges(registration.id); await fetchAttendedEventDetails(attended); } else { setDisplayName("Name"); @@ -342,7 +362,6 @@ export default function ProfilePage() { setIsEditingName(false); setDisplayName(updated.unique_name ?? displayName); - setBadges(updated.badges ?? badges); setStatusMessage("Display name saved."); } catch (error) { const message = getErrorMessage(error); @@ -590,15 +609,8 @@ export default function ProfilePage() {

) : badges.length > 0 ? (
- {badges.map((badge, index) => ( -
- - {badge} - -
+ {badges.map((award) => ( + ))}
) : ( diff --git a/src/components/badge-card.tsx b/src/components/badge-card.tsx new file mode 100644 index 0000000..249ed3c --- /dev/null +++ b/src/components/badge-card.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; + +import type { MemberBadgeWithBadge } from "@/lib/schemas/member-badge"; + +// Badge images are arbitrary Supabase/storage URLs and next.config.ts declares +// no remotePatterns, so next/image would throw on them. Plain is what the +// rest of the profile uses for remote images for the same reason. + +const CATEGORY_LABELS: Record = { + EVENT: "Event", + ACHIEVEMENT: "Achievement", + SPECIAL: "Special", +}; + +const formatAwardedAt = (awardedAt: string) => { + const date = new Date(awardedAt); + if (Number.isNaN(date.getTime())) return null; + + return date.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric", + }); +}; + +export function BadgeCard({ award }: { award: MemberBadgeWithBadge }) { + const { badge } = award; + const awardedOn = formatAwardedAt(award.awardedat); + const category = CATEGORY_LABELS[badge.category] ?? badge.category; + + // A dead or unreachable image URL would otherwise leave a broken-image icon + // in the tile. Dropping the image falls back to the name on its own, which is + // what the tile looked like before badges had artwork. + const [imageBroken, setImageBroken] = useState(false); + const showImage = Boolean(badge.imageurl) && !imageBroken; + + return ( +
+ {/* A button rather than a div so the detail panel is reachable by keyboard + and by tap, not just by mouse hover. */} + + +
+

{badge.name}

+ +

+ {category} +

+ + {badge.description ? ( +

+ {badge.description} +

+ ) : null} + + {badge.criteria ? ( +

+ {badge.criteria} +

+ ) : null} + + {awardedOn ? ( +

+ Awarded {awardedOn} +

+ ) : null} +
+
+ ); +} From c674fe7b5c89b22642f1a159a43195b5a56c5ea0 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Wed, 23 Sep 2026 18:07:38 +1200 Subject: [PATCH 14/28] fix: delete attendanceid in member-badge schema as the database does not contain this for now --- src/lib/schemas/member-badge.ts | 1 - src/services/member-badges/member-badges-service.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/schemas/member-badge.ts b/src/lib/schemas/member-badge.ts index 2cafe90..3c4bdcc 100644 --- a/src/lib/schemas/member-badge.ts +++ b/src/lib/schemas/member-badge.ts @@ -10,7 +10,6 @@ import { badgeSchema } from "./badge"; const memberBadgeFieldsSchema = z.object({ badgeid: z.uuid(), memberid: z.number().int().positive(), - attendanceid: z.uuid().nullable().default(null), }); const memberBadgeInsertSchema = memberBadgeFieldsSchema; diff --git a/src/services/member-badges/member-badges-service.tsx b/src/services/member-badges/member-badges-service.tsx index 1b0bafc..5e6eb4c 100644 --- a/src/services/member-badges/member-badges-service.tsx +++ b/src/services/member-badges/member-badges-service.tsx @@ -30,7 +30,7 @@ export default class MemberBadgesService { /** * Awards a badge to a member. Idempotent: awarding a badge the member * already holds returns the existing row rather than throwing. - * @param award The badge, member, and optionally the attendance that earned it. + * @param award The badge and the member receiving it. * @returns The member_badges row. */ public async awardBadge(award: MemberBadgeInsert): Promise { From c263ab9af2e1d0ead4a54fc337f3f1d3afc792b7 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Wed, 23 Sep 2026 18:16:00 +1200 Subject: [PATCH 15/28] fix: place the badges section align on the top --- src/app/profile/[id]/page.tsx | 4 ++-- src/app/profile/page.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/profile/[id]/page.tsx b/src/app/profile/[id]/page.tsx index c7235d4..32ad2b8 100644 --- a/src/app/profile/[id]/page.tsx +++ b/src/app/profile/[id]/page.tsx @@ -126,8 +126,8 @@ export default function PublicProfilePage() {
-
-
+
+

Badges

diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 4c7f0a5..18248fe 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -597,9 +597,9 @@ export default function ProfilePage() {
-
+
{/* Badges*/} -
+

Badges

From 76657816942931136f72feabcaf58e4d01183165 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Wed, 23 Sep 2026 18:22:30 +1200 Subject: [PATCH 16/28] feat: align style more to our design team. --- src/components/badge-card.tsx | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/components/badge-card.tsx b/src/components/badge-card.tsx index 249ed3c..a349b6b 100644 --- a/src/components/badge-card.tsx +++ b/src/components/badge-card.tsx @@ -31,18 +31,18 @@ export function BadgeCard({ award }: { award: MemberBadgeWithBadge }) { const category = CATEGORY_LABELS[badge.category] ?? badge.category; // A dead or unreachable image URL would otherwise leave a broken-image icon - // in the tile. Dropping the image falls back to the name on its own, which is - // what the tile looked like before badges had artwork. + // in the circle. Dropping the image falls back to the badge's initial. const [imageBroken, setImageBroken] = useState(false); const showImage = Boolean(badge.imageurl) && !imageBroken; return ( -
+
{/* A button rather than a div so the detail panel is reachable by keyboard - and by tap, not just by mouse hover. */} + and by tap, not just by mouse hover. The badge is shown as the image + alone; its name and details live in the hover panel below. */}
Date: Fri, 25 Sep 2026 18:23:30 +1200 Subject: [PATCH 17/28] create function to upload badge image and testing cases --- src/services/badges/badges-service.test.ts | 66 ++++++++++++++++++++++ src/services/badges/badges-service.tsx | 35 ++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/services/badges/badges-service.test.ts b/src/services/badges/badges-service.test.ts index e49ff15..0eb15c0 100644 --- a/src/services/badges/badges-service.test.ts +++ b/src/services/badges/badges-service.test.ts @@ -338,4 +338,70 @@ describe("BadgesService", () => { ).rejects.toMatchObject({ code: "42501" }); }); }); + + describe("uploadBadgeImage", () => { + const PUBLIC_URL = "https://example.supabase.co/storage/v1/object/public"; + + /** A stand-in for supabase.storage that records the upload it was given. */ + function fakeStorage(uploadError: unknown = null) { + const upload = vi.fn(() => Promise.resolve({ error: uploadError })); + const getPublicUrl = vi.fn((path: string) => ({ + data: { publicUrl: `${PUBLIC_URL}/badges/${path}` }, + })); + const bucket = vi.fn(() => ({ upload, getPublicUrl })); + + return { + client: { storage: { from: bucket } } as unknown as SupabaseClient, + bucket, + upload, + }; + } + + const image = (name = "Trophy.PNG") => + new File(["png-bytes"], name, { type: "image/png" }); + + it("uploads to the badges bucket under badge_image with a random name", async () => { + const fake = fakeStorage(); + + const { imagePath } = await new BadgesService( + fake.client, + ).uploadBadgeImage(image()); + + expect(fake.bucket).toHaveBeenCalledWith("badges"); + expect(imagePath).toMatch(/^badge_image\/[0-9a-f-]{36}\.png$/); + expect(fake.upload).toHaveBeenCalledWith( + imagePath, + expect.any(File), + expect.objectContaining({ upsert: false, contentType: "image/png" }), + ); + }); + + it("returns the public URL of the uploaded file", async () => { + const fake = fakeStorage(); + + const { imagePath, imageUrl } = await new BadgesService( + fake.client, + ).uploadBadgeImage(image()); + + expect(imageUrl).toBe(`${PUBLIC_URL}/badges/${imagePath}`); + }); + + it("rejects a file with no extension without uploading", async () => { + const fake = fakeStorage(); + + await expect( + new BadgesService(fake.client).uploadBadgeImage(image("trophy")), + ).rejects.toThrow("Badge image must have a file extension."); + + expect(fake.upload).not.toHaveBeenCalled(); + }); + + it("throws the storage error when the upload fails", async () => { + const fake = fakeStorage({ message: "Bucket not found" }); + + await expect( + new BadgesService(fake.client).uploadBadgeImage(image()), + ).rejects.toMatchObject({ message: "Bucket not found" }); + }); + }); }); diff --git a/src/services/badges/badges-service.tsx b/src/services/badges/badges-service.tsx index 7f568ae..4dd25ab 100644 --- a/src/services/badges/badges-service.tsx +++ b/src/services/badges/badges-service.tsx @@ -11,6 +11,11 @@ import { getSupabase } from "../supabase"; const TABLE = "badges"; +// The bucket must be public: imageurl stores a plain public URL, the same way +// event images do, rather than a signed URL that would eventually expire. +const IMAGE_BUCKET = "badges"; +const IMAGE_FOLDER = "badge_image"; + /** Drops keys the caller left undefined so a patch never clears a column by accident. */ function definedFields(patch: T): Partial { return Object.fromEntries( @@ -117,6 +122,36 @@ export default class BadgesService { return data as Badge; } + /** + * Uploads a badge image to storage under a random name, so two badges whose + * files share a name can't overwrite each other. + * @param file The image to upload. + * @returns The storage path and the public URL to store in `imageurl`. + */ + public async uploadBadgeImage( + file: File, + ): Promise<{ imagePath: string; imageUrl: string }> { + const extension = file.name.split(".").pop()?.toLowerCase(); + if (!extension || extension === file.name.toLowerCase()) { + throw new Error("Badge image must have a file extension."); + } + + const imagePath = `${IMAGE_FOLDER}/${crypto.randomUUID()}.${extension}`; + + const { error } = await this.db.storage + .from(IMAGE_BUCKET) + .upload(imagePath, file, { + cacheControl: "3600", + upsert: false, + contentType: file.type, + }); + + if (error) throw error; + + const { data } = this.db.storage.from(IMAGE_BUCKET).getPublicUrl(imagePath); + return { imagePath, imageUrl: data.publicUrl }; + } + /** * Deletes a {@link Badge} by its ID. Any member_badges rows referencing it * are removed too, via the foreign key's ON DELETE CASCADE. From f5fce5fa7430e3d086410700f2a6af30df8136f0 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Fri, 25 Sep 2026 18:47:56 +1200 Subject: [PATCH 18/28] route for creating badge and badge images --- src/app/api/admin/badges/image/route.ts | 71 +++++++++++++++++++++++++ src/app/api/admin/badges/route.ts | 70 ++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/app/api/admin/badges/image/route.ts create mode 100644 src/app/api/admin/badges/route.ts diff --git a/src/app/api/admin/badges/image/route.ts b/src/app/api/admin/badges/image/route.ts new file mode 100644 index 0000000..29b2632 --- /dev/null +++ b/src/app/api/admin/badges/image/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyMessage } from "viem"; +import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import BadgesService from "@/services/badges/badges-service"; +import { getSupabaseAdmin } from "@/services/supabase-admin"; + +async function verifyAdminAuth(req: NextRequest) { + const address = req.headers.get("x-admin-address")?.toLowerCase(); + const signature = req.headers.get("x-admin-signature"); + const timestamp = req.headers.get("x-admin-timestamp"); + + if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + return false; + + const now = Date.now(); + if (now - parseInt(timestamp) > 5 * 60 * 1000) return false; + + try { + const valid = await verifyMessage({ + address: address as `0x${string}`, + message: `Admin Auth ${timestamp}`, + signature: signature as `0x${string}`, + }); + return valid; + } catch { + return false; + } +} + +// POST - upload a badge image, returning the public URL to save as imageurl +export async function POST(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + try { + const formData = await req.formData(); + const file = formData.get("image"); + + if (!(file instanceof File)) { + return NextResponse.json( + { error: "Missing image file" }, + { status: 400 }, + ); + } + + // Unlike the events bucket, the badges bucket has no MIME restriction of + // its own, so this is the only thing keeping non-images out of it. + if (!file.type.startsWith("image/")) { + return NextResponse.json( + { error: "Badge image must be an image file" }, + { status: 400 }, + ); + } + + const uploaded = await new BadgesService( + getSupabaseAdmin(), + ).uploadBadgeImage(file); + + return NextResponse.json({ + image_path: uploaded.imagePath, + imageurl: uploaded.imageUrl, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { error: message || "Image upload failed" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/admin/badges/route.ts b/src/app/api/admin/badges/route.ts new file mode 100644 index 0000000..00e68c6 --- /dev/null +++ b/src/app/api/admin/badges/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyMessage } from "viem"; +import { z } from "zod"; +import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import BadgesService from "@/services/badges/badges-service"; +import { getSupabaseAdmin } from "@/services/supabase-admin"; + +// Postgres errors caused by the request's data rather than the server: +// foreign key (e.g. an eventid with no matching event) and check constraints. +const CLIENT_ERROR_CODES = new Set(["23503", "23514"]); + +async function verifyAdminAuth(req: NextRequest) { + const address = req.headers.get("x-admin-address")?.toLowerCase(); + const signature = req.headers.get("x-admin-signature"); + const timestamp = req.headers.get("x-admin-timestamp"); + + if (!address || !signature || !timestamp || !isAllowedAdminAddress(address)) + return false; + + const now = Date.now(); + if (now - parseInt(timestamp) > 5 * 60 * 1000) return false; + + try { + const valid = await verifyMessage({ + address: address as `0x${string}`, + message: `Admin Auth ${timestamp}`, + signature: signature as `0x${string}`, + }); + return valid; + } catch { + return false; + } +} + +// POST - create a badge. The body is validated by badgeInsertSchema, which +// also enforces that an EVENT badge carries an eventid. +export async function POST(req: NextRequest) { + const isAuth = await verifyAdminAuth(req); + if (!isAuth) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + try { + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 }, + ); + } + + const badge = await new BadgesService(getSupabaseAdmin()).createBadge(body); + return NextResponse.json({ badge }); + } catch (err: unknown) { + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: err.issues.map((issue) => issue.message).join("; ") }, + { status: 400 }, + ); + } + + const code = (err as { code?: string } | null)?.code; + const message = + (err as { message?: string } | null)?.message || "Failed to create badge"; + + return NextResponse.json( + { error: message }, + { status: code && CLIENT_ERROR_CODES.has(code) ? 400 : 500 }, + ); + } +} From 28c1ca98e7660facd1198dc9a4e1cdead8ff93d6 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Thu, 1 Oct 2026 10:45:23 +1300 Subject: [PATCH 19/28] feat: change the database, so that member badge and badge are cascade but not event with badge. --- ...00_badge_event_links_and_save_function.sql | 212 ++++++++++++++++++ 1 file changed, 212 insertions(+) create mode 100644 supabase/migrations/20261001120000_badge_event_links_and_save_function.sql diff --git a/supabase/migrations/20261001120000_badge_event_links_and_save_function.sql b/supabase/migrations/20261001120000_badge_event_links_and_save_function.sql new file mode 100644 index 0000000..e3241a0 --- /dev/null +++ b/supabase/migrations/20261001120000_badge_event_links_and_save_function.sql @@ -0,0 +1,212 @@ +-- Badge <-> event relationship rules, plus a function that saves an event and +-- its badge in one transaction. +-- +-- Run once in the Supabase SQL Editor. Wrapped in a transaction so a failure +-- part-way through leaves the database untouched. + +begin; + +-- Deleting an event keeps its badge (and everyone's awards of it); the badge +-- just stops being linked to an event. Previously NO ACTION, which made +-- deleting any event that had a badge fail. +alter table public.badges + drop constraint if exists badges_eventid_fkey, + add constraint badges_eventid_fkey + foreign key (eventid) references public.events (id) on delete set null; + +-- Deleting a badge deletes every award of it. Previously NO ACTION, which made +-- deleting any badge someone held fail. +alter table public.member_badges + drop constraint if exists member_badges_badgeid_fkey, + add constraint member_badges_badgeid_fkey + foreign key (badgeid) references public.badges (id) on delete cascade; + +-- A member can hold a given badge only once. MemberBadgesService.awardBadge +-- relies on this: it treats the unique violation as "already awarded". +alter table public.member_badges + drop constraint if exists member_badges_badgeid_memberid_key, + add constraint member_badges_badgeid_memberid_key unique (badgeid, memberid); + +-- Creates or updates an event and, optionally, its badge, atomically: if any +-- part fails, nothing is saved. +-- +-- p_event: event columns. With an "id" key the event is updated, otherwise it +-- is created. On update, a column whose key is absent is left unchanged, +-- matching PUT /api/admin/events, which omits undefined fields. +-- p_badge: badge columns (name, description, criteria, imageurl, isactive), or +-- null to leave the event's badge as it is. Creates the event's badge if it +-- has none, otherwise updates it. category is always EVENT and eventid is +-- always this event; neither is read from p_badge. +-- p_unlink_badge: detach the event's current badge (eventid -> null) without +-- deleting it, so members keep what they earned. +-- +-- Returns { event, badge, replaced_event_path, replaced_badge_imageurl }. The +-- replaced_* keys hold the previous image when this call swapped it for a new +-- one, so the caller can delete the old file from storage; otherwise null. +create or replace function public.save_event_with_badge( + p_event jsonb, + p_badge jsonb default null, + p_unlink_badge boolean default false +) +returns jsonb +language plpgsql +set search_path = '' +as $$ +declare + v_event public.events; + v_old_event public.events; + v_is_update boolean := p_event ? 'id'; + v_badge public.badges; + v_old_badge public.badges; + v_had_badge boolean := false; + v_has_badge boolean := false; +begin + if p_badge is not null and p_unlink_badge then + raise exception 'Cannot save and unlink a badge in the same call' + using errcode = '22023'; + end if; + + if v_is_update then + select * into v_old_event + from public.events + where id = (p_event ->> 'id')::uuid + for update; + + if not found then + raise exception 'Event % not found', p_event ->> 'id' + using errcode = 'P0002'; + end if; + + update public.events set + title = case when p_event ? 'title' + then p_event ->> 'title' else title end, + description = case when p_event ? 'description' + then p_event ->> 'description' else description end, + start_time = case when p_event ? 'start_time' + then (p_event ->> 'start_time')::timestamptz else start_time end, + end_time = case when p_event ? 'end_time' + then (p_event ->> 'end_time')::timestamptz else end_time end, + check_in_open_time = case when p_event ? 'check_in_open_time' + then (p_event ->> 'check_in_open_time')::timestamptz + else check_in_open_time end, + check_in_close_time = case when p_event ? 'check_in_close_time' + then (p_event ->> 'check_in_close_time')::timestamptz + else check_in_close_time end, + event_url = case when p_event ? 'event_url' + then p_event ->> 'event_url' else event_url end, + event_path = case when p_event ? 'event_path' + then p_event ->> 'event_path' else event_path end, + location = case when p_event ? 'location' + then p_event ->> 'location' else location end, + capacity = case when p_event ? 'capacity' + then (p_event ->> 'capacity')::integer else capacity end + where id = v_old_event.id + returning * into v_event; + else + insert into public.events ( + title, + description, + start_time, + end_time, + check_in_open_time, + check_in_close_time, + event_url, + event_path, + location, + capacity + ) + values ( + p_event ->> 'title', + p_event ->> 'description', + (p_event ->> 'start_time')::timestamptz, + (p_event ->> 'end_time')::timestamptz, + (p_event ->> 'check_in_open_time')::timestamptz, + (p_event ->> 'check_in_close_time')::timestamptz, + p_event ->> 'event_url', + p_event ->> 'event_path', + p_event ->> 'location', + (p_event ->> 'capacity')::integer + ) + returning * into v_event; + end if; + + -- badges.eventid is unique, so an event has at most one badge. + select * into v_old_badge + from public.badges + where eventid = v_event.id + for update; + v_had_badge := found; + + if p_unlink_badge then + if v_had_badge then + update public.badges set eventid = null where id = v_old_badge.id; + end if; + elsif p_badge is not null then + if v_had_badge then + update public.badges set + name = case when p_badge ? 'name' + then p_badge ->> 'name' else name end, + description = case when p_badge ? 'description' + then p_badge ->> 'description' else description end, + criteria = case when p_badge ? 'criteria' + then p_badge ->> 'criteria' else criteria end, + imageurl = case when p_badge ? 'imageurl' + then p_badge ->> 'imageurl' else imageurl end, + isactive = case when p_badge ? 'isactive' + then (p_badge ->> 'isactive')::boolean else isactive end + where id = v_old_badge.id + returning * into v_badge; + else + insert into public.badges ( + name, + description, + criteria, + imageurl, + category, + eventid, + isactive + ) + values ( + p_badge ->> 'name', + p_badge ->> 'description', + p_badge ->> 'criteria', + p_badge ->> 'imageurl', + 'EVENT', + v_event.id, + coalesce((p_badge ->> 'isactive')::boolean, true) + ) + returning * into v_badge; + end if; + v_has_badge := true; + elsif v_had_badge then + v_badge := v_old_badge; + v_has_badge := true; + end if; + + return jsonb_build_object( + 'event', to_jsonb(v_event), + 'badge', case when v_has_badge then to_jsonb(v_badge) end, + 'replaced_event_path', case + when v_is_update + and v_old_event.event_path is distinct from v_event.event_path + then v_old_event.event_path + end, + 'replaced_badge_imageurl', case + when v_had_badge + and p_badge is not null + and v_old_badge.imageurl is distinct from v_badge.imageurl + then v_old_badge.imageurl + end + ); +end; +$$; + +-- Supabase grants EXECUTE on new public functions to anon and authenticated +-- by default, which would let anyone call this with the public key and skip +-- the admin check. Only the server (service role) may call it. +revoke all on function public.save_event_with_badge(jsonb, jsonb, boolean) + from public, anon, authenticated; +grant execute on function public.save_event_with_badge(jsonb, jsonb, boolean) + to service_role; + +commit; From 25c83e8e2c1b8ace82ce110c4bb8a3629ffd1799 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Thu, 1 Oct 2026 10:55:55 +1300 Subject: [PATCH 20/28] feat: two helper function to get and delete badge image through its path, in case when image is stored but badge is not created. --- src/lib/schemas/badge.ts | 7 +- src/services/badges/badges-service.test.ts | 129 ++++++++++++++++++--- src/services/badges/badges-service.tsx | 52 ++++++++- 3 files changed, 166 insertions(+), 22 deletions(-) diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts index b43bced..fddc50c 100644 --- a/src/lib/schemas/badge.ts +++ b/src/lib/schemas/badge.ts @@ -25,9 +25,10 @@ const badgeFieldsSchema = z.object({ // a default on `badgeFieldsSchema` would leak into `badgeUpdateSchema` and make // every patch silently overwrite the columns the caller never mentioned. // -// The EVENT rule is early feedback only. A partial update can't check it in -// isolation, since a patch may change `category` alone while `eventid` is -// already set on the row, so the database constraint is the real guarantee. +// The EVENT rule applies at creation only, and nothing in the database enforces +// it. That is deliberate: deleting an event sets its badge's eventid to null +// (ON DELETE SET NULL) and the badge stays EVENT, so an EVENT badge without an +// event is a valid state afterwards. Hence no such rule on badgeUpdateSchema. const badgeInsertSchema = badgeFieldsSchema .extend({ description: badgeFieldsSchema.shape.description.default(null), diff --git a/src/services/badges/badges-service.test.ts b/src/services/badges/badges-service.test.ts index 0eb15c0..156894a 100644 --- a/src/services/badges/badges-service.test.ts +++ b/src/services/badges/badges-service.test.ts @@ -63,6 +63,34 @@ function dbError(code: string, message = "stubbed failure") { return { code, message, details: null, hint: null }; } +const PUBLIC_URL = "https://example.supabase.co/storage/v1/object/public"; + +/** + * A stand-in for supabase.storage that records the calls it was given. + * getPublicUrl mirrors storage-js, which returns + * encodeURI(`${storageUrl}/object/public/${bucket}/${path}`). + */ +function fakeStorage({ + uploadError = null, + removeError = null, +}: { uploadError?: unknown; removeError?: unknown } = {}) { + const upload = vi.fn(() => Promise.resolve({ error: uploadError })); + const remove = vi.fn(() => + Promise.resolve({ data: removeError ? null : [], error: removeError }), + ); + const getPublicUrl = vi.fn((path: string) => ({ + data: { publicUrl: encodeURI(`${PUBLIC_URL}/badges/${path}`) }, + })); + const bucket = vi.fn(() => ({ upload, remove, getPublicUrl })); + + return { + client: { storage: { from: bucket } } as unknown as SupabaseClient, + bucket, + upload, + remove, + }; +} + function badgeRow(overrides: Partial = {}): Badge { return { id: BADGE_ID, @@ -340,23 +368,6 @@ describe("BadgesService", () => { }); describe("uploadBadgeImage", () => { - const PUBLIC_URL = "https://example.supabase.co/storage/v1/object/public"; - - /** A stand-in for supabase.storage that records the upload it was given. */ - function fakeStorage(uploadError: unknown = null) { - const upload = vi.fn(() => Promise.resolve({ error: uploadError })); - const getPublicUrl = vi.fn((path: string) => ({ - data: { publicUrl: `${PUBLIC_URL}/badges/${path}` }, - })); - const bucket = vi.fn(() => ({ upload, getPublicUrl })); - - return { - client: { storage: { from: bucket } } as unknown as SupabaseClient, - bucket, - upload, - }; - } - const image = (name = "Trophy.PNG") => new File(["png-bytes"], name, { type: "image/png" }); @@ -397,11 +408,93 @@ describe("BadgesService", () => { }); it("throws the storage error when the upload fails", async () => { - const fake = fakeStorage({ message: "Bucket not found" }); + const fake = fakeStorage({ + uploadError: { message: "Bucket not found" }, + }); await expect( new BadgesService(fake.client).uploadBadgeImage(image()), ).rejects.toMatchObject({ message: "Bucket not found" }); }); }); + + describe("getBadgeImagePath", () => { + const pathOf = (url: string | null | undefined) => + new BadgesService(fakeStorage().client).getBadgeImagePath(url); + + it("recovers the path from a public URL this service produced", async () => { + const fake = fakeStorage(); + const service = new BadgesService(fake.client); + const file = new File(["png-bytes"], "Trophy.png", { type: "image/png" }); + + const { imagePath, imageUrl } = await service.uploadBadgeImage(file); + + expect(service.getBadgeImagePath(imageUrl)).toBe(imagePath); + }); + + it("decodes characters that getPublicUrl encoded", () => { + expect(pathOf(encodeURI(`${PUBLIC_URL}/badges/badge_image/my badge.png`))).toBe( + "badge_image/my badge.png", + ); + }); + + it("ignores a query string", () => { + expect(pathOf(`${PUBLIC_URL}/badges/badge_image/a.png?download=`)).toBe( + "badge_image/a.png", + ); + }); + + it("returns null for a signed URL", () => { + expect( + pathOf( + "https://example.supabase.co/storage/v1/object/sign/badges/badge_image/Trophy.png?token=abc", + ), + ).toBeNull(); + }); + + it("returns null for another bucket, host or folder", () => { + expect(pathOf(`${PUBLIC_URL}/events/badge_image/a.png`)).toBeNull(); + expect( + pathOf("https://elsewhere.example.com/storage/v1/object/public/badges/badge_image/a.png"), + ).toBeNull(); + expect(pathOf(`${PUBLIC_URL}/badges/other/a.png`)).toBeNull(); + }); + + it("returns null for a path that climbs out of the folder", () => { + expect(pathOf(`${PUBLIC_URL}/badges/badge_image/../secret.png`)).toBeNull(); + }); + + it("returns null for the bare folder", () => { + expect(pathOf(`${PUBLIC_URL}/badges/badge_image/`)).toBeNull(); + }); + + it("returns null for a malformed escape", () => { + expect(pathOf(`${PUBLIC_URL}/badges/badge_image/%E0%A4%A.png`)).toBeNull(); + }); + + it("returns null when there is no URL", () => { + expect(pathOf(null)).toBeNull(); + expect(pathOf(undefined)).toBeNull(); + expect(pathOf("")).toBeNull(); + }); + }); + + describe("deleteBadgeImage", () => { + it("removes the file from the badges bucket", async () => { + const fake = fakeStorage(); + + await new BadgesService(fake.client).deleteBadgeImage("badge_image/a.png"); + + expect(fake.bucket).toHaveBeenCalledWith("badges"); + expect(fake.remove).toHaveBeenCalledWith(["badge_image/a.png"]); + }); + + it("throws the storage error when the delete fails", async () => { + const fake = fakeStorage({ removeError: { message: "Access denied" } }); + + await expect( + new BadgesService(fake.client).deleteBadgeImage("badge_image/a.png"), + ).rejects.toMatchObject({ message: "Access denied" }); + }); + }); }); diff --git a/src/services/badges/badges-service.tsx b/src/services/badges/badges-service.tsx index 4dd25ab..5eb8376 100644 --- a/src/services/badges/badges-service.tsx +++ b/src/services/badges/badges-service.tsx @@ -152,9 +152,59 @@ export default class BadgesService { return { imagePath, imageUrl: data.publicUrl }; } + /** + * Recovers the storage path of a badge image from its `imageurl`, so the file + * can be deleted. badges has no path column, but a public URL is just the + * path appended to a fixed prefix. + * + * Only public URLs into this bucket's badge_image folder qualify. Anything + * else, such as an older signed URL or an image hosted elsewhere, returns + * `null`, so callers never delete a file this service didn't upload. + * @param imageUrl The badge's `imageurl`. + * @returns The storage path, or `null` if the URL isn't one of ours. + */ + public getBadgeImagePath(imageUrl: string | null | undefined): string | null { + if (!imageUrl) return null; + + // getPublicUrl builds encodeURI(`${storageUrl}/object/public/${bucket}/${path}`), + // so an empty path yields exactly the prefix every badge image URL shares. + const prefix = this.db.storage.from(IMAGE_BUCKET).getPublicUrl("").data + .publicUrl; + if (!imageUrl.startsWith(prefix)) return null; + + let path: string; + try { + path = decodeURI(imageUrl.slice(prefix.length).split("?")[0]); + } catch { + return null; + } + + const insideFolder = + path.startsWith(`${IMAGE_FOLDER}/`) && + path.length > IMAGE_FOLDER.length + 1 && + !path.split("/").includes(".."); + + return insideFolder ? path : null; + } + + /** + * Deletes a badge image from storage. + * @param imagePath The storage path, as returned by {@link uploadBadgeImage} + * or {@link getBadgeImagePath}. + */ + public async deleteBadgeImage(imagePath: string): Promise { + const { error } = await this.db.storage + .from(IMAGE_BUCKET) + .remove([imagePath]); + + if (error) throw error; + } + /** * Deletes a {@link Badge} by its ID. Any member_badges rows referencing it - * are removed too, via the foreign key's ON DELETE CASCADE. + * are removed too, via the foreign key's ON DELETE CASCADE (added in + * migration 20261001120000). The image file is not deleted; pass the badge's + * imageurl through {@link getBadgeImagePath} and {@link deleteBadgeImage}. * @param id The ID of the badge to delete. */ public async deleteBadgeById(id: string): Promise { From 25eae36eeca9643d718b938cb754c11d07b08c35 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Thu, 1 Oct 2026 13:57:33 +1300 Subject: [PATCH 21/28] feat: add the backend route and service event with badge save and delete function --- src/app/api/admin/events/route.ts | 226 +++++++++++++++++++++--------- src/lib/schemas/badge.ts | 13 +- src/services/event-service.ts | 173 ++++++++++++++++++++++- 3 files changed, 338 insertions(+), 74 deletions(-) diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index f0607bb..1ea9585 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -2,7 +2,11 @@ import { NextRequest, NextResponse } from "next/server"; import { getSupabase } from "@/services/supabase"; import { RegistrationService } from "@/services/registrations/registrations-service"; import { verifyMessage } from "viem"; +import { z } from "zod"; import { isAllowedAdminAddress } from "@/lib/admin-auth"; +import type { ClubEvent } from "@/lib/events"; +import { eventBadgeSchema } from "@/lib/schemas/badge"; +import { EventService } from "@/services/event-service"; import { getSupabaseAdmin } from "@/services/supabase-admin"; async function verifyAdminAuth(req: NextRequest) { @@ -56,14 +60,103 @@ export async function GET(req: NextRequest) { } } -// PUT - create or update event +// Postgres errors caused by the request's data rather than the server: bad +// parameter, invalid text/datetime representation, datetime out of range, +// not-null, foreign key, unique and check violations. +const CLIENT_ERROR_CODES = new Set([ + "22023", + "22P02", + "22007", + "22008", + "23502", + "23503", + "23505", + "23514", +]); + +class BadRequestError extends Error {} + +/** Returns the named form field as an image file, or null if it's absent. */ +function imageField(formData: FormData, name: string): File | null { + const value = formData.get(name); + if (value === null || value === "") return null; + + if (!(value instanceof File) || !value.type.startsWith("image/")) { + throw new BadRequestError(`${name} must be an image file`); + } + return value; +} + +/** + * Reads the request in either of the two shapes it may take: + * + * - multipart/form-data: a `payload` field holding JSON + * `{ event, badge?, unlink_badge?, assignedEmails? }`, plus optional + * `event_image` and `badge_image` files. Used by the badge-aware form. + * - JSON: the flat event fields plus `assignedEmails`, as sent before badges + * existed. It has no badge support and its images are uploaded separately. + */ +async function readSaveRequest(req: NextRequest) { + const contentType = req.headers.get("content-type") ?? ""; + + if (contentType.includes("multipart/form-data")) { + const formData = await req.formData(); + const payload = formData.get("payload"); + + if (typeof payload !== "string") { + throw new BadRequestError("Missing payload field"); + } + + let body: unknown; + try { + body = JSON.parse(payload); + } catch { + throw new BadRequestError("payload must be valid JSON"); + } + + if (!body || typeof body !== "object") { + throw new BadRequestError("payload must be a JSON object"); + } + + const { event, badge, unlink_badge, assignedEmails } = body as Record< + string, + unknown + >; + + return { + event, + badge, + unlinkBadge: unlink_badge === true, + assignedEmails, + eventImage: imageField(formData, "event_image"), + badgeImage: imageField(formData, "badge_image"), + }; + } + + const json = await req.json().catch(() => null); + if (!json || typeof json !== "object") { + throw new BadRequestError("Request body must be a JSON object"); + } + + const { assignedEmails, ...event } = json as Record; + return { + event, + badge: null, + unlinkBadge: false, + assignedEmails, + eventImage: null, + badgeImage: null, + }; +} + +// PUT - create or update an event, and optionally its badge, all or nothing export async function PUT(req: NextRequest) { const isAuth = await verifyAdminAuth(req); if (!isAuth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); try { - const body = await req.json(); + const request = await readSaveRequest(req); const { id, title, @@ -76,88 +169,68 @@ export async function PUT(req: NextRequest) { event_url, location, capacity, - } = body || {}; + } = (request.event || {}) as Partial; if (!title || !start_time || !end_time) { - return NextResponse.json( - { error: "Missing required fields: title, start_time, end_time" }, - { status: 400 }, + throw new BadRequestError( + "Missing required fields: title, start_time, end_time", ); } // Basic validation: end_time > start_time if (new Date(end_time) <= new Date(start_time)) { - return NextResponse.json( - { error: "end_time must be after start_time" }, - { status: 400 }, - ); + throw new BadRequestError("end_time must be after start_time"); } if (check_in_open_time && check_in_close_time) { if (new Date(check_in_close_time) <= new Date(check_in_open_time)) { - return NextResponse.json( - { error: "check_in_close_time must be after check_in_open_time" }, - { status: 400 }, + throw new BadRequestError( + "check_in_close_time must be after check_in_open_time", ); } } - const supabase = getSupabase(); + // category, eventid and imageurl are stripped here: the database function + // sets the first two, and imageurl only ever comes from badge_image. + const badge = + request.badge == null ? null : eventBadgeSchema.parse(request.badge); + + if (badge && request.unlinkBadge) { + throw new BadRequestError( + "Cannot save a badge and unlink it in the same request", + ); + } + + if (request.badgeImage && !badge) { + throw new BadRequestError("badge_image was sent without a badge"); + } - if (id) { - const updatePayload: any = { + const saved = await EventService.saveEventWithBadge(getSupabaseAdmin(), { + // An update leaves out the columns that are undefined here, so they keep + // their current values. + event: { + id, title, description, start_time, end_time, check_in_open_time, check_in_close_time, - }; - if (event_path !== undefined) updatePayload.event_path = event_path; - if (event_url !== undefined) updatePayload.event_url = event_url; - if (location !== undefined) updatePayload.location = location; - if (capacity !== undefined) updatePayload.capacity = capacity; - - const { data, error } = await supabase - .from("events") - .update(updatePayload) - .eq("id", id) - .select() - .single(); - - if (error) - return NextResponse.json({ error: error.message }, { status: 500 }); - - return NextResponse.json({ event: data }); - } - - // create - const insertPayload: any = { - title, - description, - start_time, - end_time, - check_in_open_time, - check_in_close_time, - }; - if (event_path !== undefined) insertPayload.event_path = event_path; - if (event_url !== undefined) insertPayload.event_url = event_url; - if (location !== undefined) insertPayload.location = location; - if (capacity !== undefined) insertPayload.capacity = capacity; - - const { data, error } = await supabase - .from("events") - .insert([insertPayload]) - .select() - .single(); - - if (error) - return NextResponse.json({ error: error.message }, { status: 500 }); + event_path, + event_url, + location, + capacity, + }, + badge, + unlinkBadge: request.unlinkBadge, + eventImage: request.eventImage, + badgeImage: request.badgeImage, + }); - // If the request included assignedEmails, add the event name to each - // user's `events_attended` list (if that registration exists). - const assignedEmails: string[] | undefined = body?.assignedEmails; - if (assignedEmails && Array.isArray(assignedEmails) && data) { + // On create, add the event name to each assigned user's `events_attended` + // list (if that registration exists). + const assignedEmails = request.assignedEmails; + if (!id && assignedEmails && Array.isArray(assignedEmails)) { const eventName = title; for (const email of assignedEmails) { try { @@ -169,12 +242,31 @@ export async function PUT(req: NextRequest) { } } - return NextResponse.json({ event: data }); - } catch (err: any) { - return NextResponse.json( - { error: err.message || "Failed" }, - { status: 500 }, - ); + return NextResponse.json(saved); + } catch (err: unknown) { + if (err instanceof BadRequestError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + + if (err instanceof z.ZodError) { + return NextResponse.json( + { error: err.issues.map((issue) => issue.message).join("; ") }, + { status: 400 }, + ); + } + + const code = (err as { code?: string } | null)?.code; + const message = + (err as { message?: string } | null)?.message || "Failed to save event"; + + const status = + code === "P0002" + ? 404 + : code && CLIENT_ERROR_CODES.has(code) + ? 400 + : 500; + + return NextResponse.json({ error: message }, { status }); } } diff --git a/src/lib/schemas/badge.ts b/src/lib/schemas/badge.ts index fddc50c..bb87bd0 100644 --- a/src/lib/schemas/badge.ts +++ b/src/lib/schemas/badge.ts @@ -44,6 +44,15 @@ const badgeInsertSchema = badgeFieldsSchema const badgeUpdateSchema = badgeFieldsSchema.partial(); +// The badge half of an event save (PUT /api/admin/events). category and eventid +// are absent because save_event_with_badge sets them itself, and imageurl is +// absent because it only ever comes from an image the route uploaded — so it is +// always a URL that getBadgeImagePath can map back to a file for cleanup. +// An omitted optional field keeps its current value when updating. +const eventBadgeSchema = badgeFieldsSchema + .pick({ name: true, description: true, criteria: true, isactive: true }) + .partial({ description: true, criteria: true, isactive: true }); + const badgeSchema = badgeFieldsSchema.extend({ id: z.uuid(), created_at: z.string(), @@ -55,5 +64,7 @@ export type Badge = z.infer; export type BadgeInsert = z.input; /** A partial patch accepted by `updateBadgeById`. */ export type BadgeUpdate = z.input; +/** The badge fields an admin fills in alongside an event. */ +export type EventBadge = z.infer; -export { badgeSchema, badgeInsertSchema, badgeUpdateSchema }; +export { badgeSchema, badgeInsertSchema, badgeUpdateSchema, eventBadgeSchema }; diff --git a/src/services/event-service.ts b/src/services/event-service.ts index 7f243fb..65baf53 100644 --- a/src/services/event-service.ts +++ b/src/services/event-service.ts @@ -1,9 +1,54 @@ -import { getSupabase } from "@/services/supabase"; -import type { ClubEvent } from "@/lib/events"; +import type { SupabaseClient } from "@supabase/supabase-js"; + +import { getSupabase } from "./supabase"; +import BadgesService from "./badges/badges-service"; +import type { ClubEvent } from "../lib/events"; +import type { Badge, EventBadge } from "../lib/schemas/badge"; const EVENT_IMAGE_BUCKET = "events"; const EVENT_IMAGE_FOLDER = "events"; +/** What {@link EventService.saveEventWithBadge} needs, already validated. */ +export type SaveEventWithBadgeInput = { + /** + * Event columns. With an `id` the event is updated, and any column left + * out keeps its current value; without one, a new event is created. + */ + event: Partial; + /** Create or update the event's badge. Omit to leave it as it is. */ + badge?: EventBadge | null; + /** Detach the event's current badge without deleting it. */ + unlinkBadge?: boolean; + /** A new event image, replacing any current one. */ + eventImage?: File | null; + /** A new badge image, replacing any current one. Requires `badge`. */ + badgeImage?: File | null; +}; + +/** The jsonb returned by the save_event_with_badge database function. */ +type SaveEventWithBadgeResult = { + event: ClubEvent; + badge: Badge | null; + replaced_event_path: string | null; + replaced_badge_imageurl: string | null; +}; + +/** + * Runs storage cleanups without letting a failure escape: by the time they + * run, the outcome of the save is already decided, and a leftover file is + * better than reporting a save that succeeded as failed (or masking the error + * that made it fail). + */ +async function cleanUpQuietly( + cleanups: Array<() => Promise>, + context: string, +) { + const results = await Promise.allSettled(cleanups.map((cleanup) => cleanup())); + for (const result of results) { + if (result.status === "rejected") console.error(context, result.reason); + } +} + export const EventService = { /** Public read used by the events page. Relies on anon SELECT being allowed. */ listEvents: async (): Promise => { @@ -44,11 +89,17 @@ export const EventService = { return `${EVENT_IMAGE_FOLDER}/${crypto.randomUUID()}.${fileExtension}`; }, - uploadEventImage: async (file: File) => { - const supabase = getSupabase(); + /** + * @param client Supabase client to use. Defaults to the shared anon client; + * server routes pass the service-role client. + */ + uploadEventImage: async ( + file: File, + client: SupabaseClient = getSupabase(), + ) => { const imagePath = EventService.getEventImagePath(file); - const { error } = await supabase.storage + const { error } = await client.storage .from(EVENT_IMAGE_BUCKET) .upload(imagePath, file, { cacheControl: "3600", @@ -60,7 +111,7 @@ export const EventService = { throw error; } - const { data } = supabase.storage + const { data } = client.storage .from(EVENT_IMAGE_BUCKET) .getPublicUrl(imagePath); @@ -69,4 +120,114 @@ export const EventService = { imageUrl: data.publicUrl, }; }, + + /** + * Deletes an event image from storage. + * @param imagePath The storage path, as stored in `events.event_path`. + * @param client Supabase client to use. Defaults to the shared anon client. + */ + deleteEventImage: async ( + imagePath: string, + client: SupabaseClient = getSupabase(), + ) => { + const { error } = await client.storage + .from(EVENT_IMAGE_BUCKET) + .remove([imagePath]); + + if (error) { + throw error; + } + }, + + /** + * Creates or updates an event and, optionally, its badge, all or nothing. + * + * Images are uploaded first, since the rows store their URLs. If anything + * after that fails, the files uploaded here are deleted again, so a failed + * save leaves nothing behind. After a successful save, any image this save + * replaced is deleted too. + * + * @param client A client allowed to call save_event_with_badge, i.e. the + * service-role client. The function is not executable by anon. + */ + saveEventWithBadge: async ( + client: SupabaseClient, + { + event, + badge = null, + unlinkBadge = false, + eventImage = null, + badgeImage = null, + }: SaveEventWithBadgeInput, + ): Promise<{ event: ClubEvent; badge: Badge | null }> => { + if (badgeImage && !badge) { + throw new Error("A badge image was given without a badge."); + } + + const badges = new BadgesService(client); + const eventPayload: Record = { ...event }; + const badgePayload: Record | null = badge + ? { ...badge } + : null; + const uploadedCleanups: Array<() => Promise> = []; + + let result: SaveEventWithBadgeResult; + + try { + if (eventImage) { + const uploaded = await EventService.uploadEventImage(eventImage, client); + uploadedCleanups.push(() => + EventService.deleteEventImage(uploaded.imagePath, client), + ); + eventPayload.event_path = uploaded.imagePath; + eventPayload.event_url = uploaded.imageUrl; + } + + if (badgeImage && badgePayload) { + const uploaded = await badges.uploadBadgeImage(badgeImage); + uploadedCleanups.push(() => badges.deleteBadgeImage(uploaded.imagePath)); + badgePayload.imageurl = uploaded.imageUrl; + } + + const { data, error } = await client.rpc("save_event_with_badge", { + p_event: eventPayload, + p_badge: badgePayload, + p_unlink_badge: unlinkBadge, + }); + + if (error) throw error; + result = data as SaveEventWithBadgeResult; + } catch (error) { + await cleanUpQuietly( + uploadedCleanups, + "Failed to delete an image uploaded for an event save that failed", + ); + throw error; + } + + const replacedCleanups: Array<() => Promise> = []; + + // Only delete paths inside the folder uploads go to, in case an older row + // points somewhere else in the bucket. + const replacedEventPath = result.replaced_event_path; + if (replacedEventPath?.startsWith(`${EVENT_IMAGE_FOLDER}/`)) { + replacedCleanups.push(() => + EventService.deleteEventImage(replacedEventPath, client), + ); + } + + const replacedBadgePath = badges.getBadgeImagePath( + result.replaced_badge_imageurl, + ); + if (replacedBadgePath) { + replacedCleanups.push(() => badges.deleteBadgeImage(replacedBadgePath)); + } + + await cleanUpQuietly( + replacedCleanups, + "Failed to delete an image replaced by an event save", + ); + + return { event: result.event, badge: result.badge }; + }, }; From bf2cbbb55126e4ebf90a46f9e9ce89b155370454 Mon Sep 17 00:00:00 2001 From: TheSKBroook Date: Thu, 1 Oct 2026 14:18:42 +1300 Subject: [PATCH 22/28] feat: frontend design to include the add badge in creation of event. --- src/app/admin/events/page.tsx | 245 ++++++++++++--------- src/app/api/admin/events/image/route.ts | 56 ----- src/components/admin/badge-form-dialog.tsx | 213 ++++++++++++++++++ 3 files changed, 360 insertions(+), 154 deletions(-) delete mode 100644 src/app/api/admin/events/image/route.ts create mode 100644 src/components/admin/badge-form-dialog.tsx diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx index 47d7144..4944933 100644 --- a/src/app/admin/events/page.tsx +++ b/src/app/admin/events/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { FilePlus2 } from "lucide-react"; +import { Award, FilePlus2, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SectionCard } from "@/components/admin/section-card"; @@ -10,6 +10,10 @@ import { adminButtonClass, adminInputClass, } from "@/components/admin/admin-field"; +import { + BadgeFormDialog, + type BadgeDraft, +} from "@/components/admin/badge-form-dialog"; import { useAdmin } from "@/components/admin/use-admin"; const formatDateString = (value?: string | null) => @@ -41,9 +45,12 @@ export default function AdminEventsPage() { const [imageFile, setImageFile] = useState(null); const [imagePreviewUrl, setImagePreviewUrl] = useState(null); const [imageUrl, setImageUrl] = useState(null); - const [imageUploading, setImageUploading] = useState(false); - const [imageError, setImageError] = useState(null); + const [saving, setSaving] = useState(false); const [editingId, setEditingId] = useState(null); + // The badge only exists here until the event is saved; the server then + // creates the event and badge together, or neither. + const [badgeDraft, setBadgeDraft] = useState(null); + const [badgeDialogOpen, setBadgeDialogOpen] = useState(false); /** Clears every event field and leaves edit mode. Backs the Reset button. */ const resetForm = () => { @@ -60,69 +67,40 @@ export default function AdminEventsPage() { setImageFile(null); setImagePreviewUrl(null); setImageUrl(null); - setImageError(null); - }; - - const uploadImage = async (): Promise<{ - event_path: string; - event_url: string; - } | null> => { - if (!imageFile) return null; - setImageError(null); - setImageUploading(true); - - try { - const formData = new FormData(); - formData.append("image", imageFile); - - const res = await adminFetch("/api/admin/events/image", { - method: "POST", - body: formData, - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ error: "Unknown error" })); - throw new Error(err.error || "Failed to upload image"); - } - - return await res.json(); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setImageError(message || "Image upload failed"); - return null; - } finally { - setImageUploading(false); - } + setBadgeDraft(null); }; const createEvent = async () => { + setSaving(true); try { - let uploadedImage: { event_path: string; event_url: string } | null = - null; - if (imageFile) { - uploadedImage = await uploadImage(); - if (!uploadedImage) { - throw new Error("Unable to upload image before saving event"); - } - } - - const payload: Record = { + const event: Record = { title, description, location: location.trim() || null, capacity: capacity.trim() ? Number(capacity) : null, start_time: startTime ? new Date(startTime).toISOString() : null, end_time: endTime ? new Date(endTime).toISOString() : null, - event_url: uploadedImage?.event_url ?? imageUrl, - event_path: uploadedImage?.event_path ?? undefined, + // Keeps the current image when no new file is chosen. A new file is + // sent as event_image below and replaces both URL and path. + event_url: imageUrl, }; - if (editingId) payload.id = editingId; + if (editingId) event.id = editingId; if (checkInOpen) - payload.check_in_open_time = new Date(checkInOpen).toISOString(); + event.check_in_open_time = new Date(checkInOpen).toISOString(); if (checkInClose) - payload.check_in_close_time = new Date(checkInClose).toISOString(); + event.check_in_close_time = new Date(checkInClose).toISOString(); + + const payload: Record = { event }; + + if (badgeDraft) { + payload.badge = { + name: badgeDraft.name, + description: badgeDraft.description, + criteria: badgeDraft.criteria, + }; + } const assigned = assignedEmailsText .split(",") @@ -130,9 +108,17 @@ export default function AdminEventsPage() { .filter(Boolean); if (assigned.length) payload.assignedEmails = assigned; + // One request carries the event, the badge and both images, so the + // server can save all of it or none of it. + const formData = new FormData(); + formData.append("payload", JSON.stringify(payload)); + if (imageFile) formData.append("event_image", imageFile); + if (badgeDraft?.imageFile) + formData.append("badge_image", badgeDraft.imageFile); + const res = await adminFetch("/api/admin/events", { method: "PUT", - body: JSON.stringify(payload), + body: formData, }); if (!res.ok) { @@ -146,8 +132,12 @@ export default function AdminEventsPage() { fetchData(); alert(wasEditing ? "Event saved" : "Event created"); } catch (err: unknown) { + // The form is left as it was, badge included, so the admin can fix the + // problem and try again. Nothing was saved. const message = err instanceof Error ? err.message : String(err); alert(message || "Failed to create event"); + } finally { + setSaving(false); } }; @@ -262,61 +252,118 @@ export default function AdminEventsPage() { /> -
- - Event Image - - {/* Native input is hidden; the label is the visible button. */} - - - {imagePreviewUrl && ( - Event preview - )} - {imageError && ( -

- {imageError} -

- )} +
+
+ + Event Image + + {/* Native input is hidden; the label is the visible button. */} + + + {imagePreviewUrl && ( + Event preview + )} +
+ +
+ + Event Badge (optional) + + + {badgeDraft ? ( +
+
+
+ {badgeDraft.imagePreviewUrl ? ( + {`${badgeDraft.name} + ) : ( + + )} +
+

+ {badgeDraft.name} +

+
+ +
+ + +
+
+ ) : ( + + )} +
+ {badgeDialogOpen && ( + { + setBadgeDraft(draft); + setBadgeDialogOpen(false); + }} + onCancel={() => setBadgeDialogOpen(false)} + /> + )} +