From e172f128e9244ec508fe9c45f8f89bd9db4ca4df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Mar 2026 02:06:37 +0000 Subject: [PATCH] Allow private competition props to have no category Relax the DB CHECK constraint from requiring user_id OR category_id to requiring user_id OR category_id OR competition_id. Add server-side validation to still require category for public competition props. Update the Zod schema in CreateEditPropForm accordingly. https://claude.ai/code/session_01SFaEaoteYPYSAmXmir97Jv --- components/forms/create-edit-prop-form.tsx | 16 +++-- lib/db_actions/props.test.ts | 67 ++++++++++++++++--- lib/db_actions/props.ts | 12 +++- ...12944723_relax-prop-category-constraint.ts | 33 +++++++++ 4 files changed, 111 insertions(+), 17 deletions(-) create mode 100644 migrations/1773712944723_relax-prop-category-constraint.ts diff --git a/components/forms/create-edit-prop-form.tsx b/components/forms/create-edit-prop-form.tsx index 50a039fd..8e9356e8 100644 --- a/components/forms/create-edit-prop-form.tsx +++ b/components/forms/create-edit-prop-form.tsx @@ -36,10 +36,18 @@ export const propFormSchema = z message: "Props associated with a competition must be public.", path: ["user_id"], }) - .refine((data) => !(data.user_id === null && data.category_id === null), { - message: "Public props must have a category", - path: ["category_id"], - }); + .refine( + (data) => + !( + data.user_id === null && + data.category_id === null && + data.competition_id === null + ), + { + message: "Public props must have a category", + path: ["category_id"], + }, + ); export type PropFormValues = z.infer; diff --git a/lib/db_actions/props.test.ts b/lib/db_actions/props.test.ts index 4ded93ab..f4f0e2df 100644 --- a/lib/db_actions/props.test.ts +++ b/lib/db_actions/props.test.ts @@ -202,14 +202,22 @@ describe("Props Unit Tests", () => { expect(result.success).toBe(true); }); - it("should allow competition props without category", async () => { + it("should allow private competition props without category", async () => { vi.mocked(getUser.getUserFromCookies).mockResolvedValue(mockUser as any); const mockTrx = { - selectFrom: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - executeTakeFirst: vi.fn().mockResolvedValue({ is_private: false }), + selectFrom: vi.fn().mockImplementation(() => { + return { + select: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + executeTakeFirst: vi.fn().mockResolvedValue({ role: "admin" }), + }), + executeTakeFirst: vi.fn().mockResolvedValue({ is_private: true }), + }), + }), + }; + }), insertInto: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue(undefined), @@ -221,9 +229,9 @@ describe("Props Unit Tests", () => { const result = await createProp({ prop: { - text: "This is a competition proposition", + text: "This is a private competition proposition", category_id: null, - competition_id: 1, // Competition prop + competition_id: 1, user_id: null, }, }); @@ -231,6 +239,35 @@ describe("Props Unit Tests", () => { expect(result.success).toBe(true); }); + it("should require category for public competition props", async () => { + vi.mocked(getUser.getUserFromCookies).mockResolvedValue(mockUser as any); + + const mockTrx = { + selectFrom: vi.fn().mockReturnThis(), + select: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + executeTakeFirst: vi.fn().mockResolvedValue({ is_private: false }), + }; + + vi.mocked(dbHelpers.withRLSAction).mockImplementation(async (userId, fn) => { + return fn(mockTrx as any); + }); + + const result = await createProp({ + prop: { + text: "This is a public competition proposition", + category_id: null, + competition_id: 1, + user_id: null, + }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("VALIDATION_ERROR"); + } + }); + describe("date validation", () => { it("should reject forecast deadline in the past", async () => { vi.mocked(getUser.getUserFromCookies).mockResolvedValue(mockUser as any); @@ -315,10 +352,18 @@ describe("Props Unit Tests", () => { const resolutionDate = new Date(Date.now() + 86400000 * 7); // 7 days from now const mockTrx = { - selectFrom: vi.fn().mockReturnThis(), - select: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - executeTakeFirst: vi.fn().mockResolvedValue({ is_private: false }), + selectFrom: vi.fn().mockImplementation(() => { + return { + select: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + executeTakeFirst: vi.fn().mockResolvedValue({ role: "admin" }), + }), + executeTakeFirst: vi.fn().mockResolvedValue({ is_private: true }), + }), + }), + }; + }), insertInto: vi.fn().mockReturnThis(), values: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue(undefined), diff --git a/lib/db_actions/props.ts b/lib/db_actions/props.ts index 81becae2..5fbfe95d 100644 --- a/lib/db_actions/props.ts +++ b/lib/db_actions/props.ts @@ -398,8 +398,7 @@ export async function createProp({ ]; } - // Category is only required for public props without a competition - // Personal props (user_id set) and competition props don't require a category + // Category is required for non-personal, non-competition props if (prop.category_id == null && prop.user_id === null && prop.competition_id === null) { validationErrors.category_id = ["Category is required"]; } @@ -445,6 +444,15 @@ export async function createProp({ return error("Competition not found", ERROR_CODES.NOT_FOUND); } + // Public competition props must have a category + if (!competition.is_private && prop.category_id == null) { + return validationError( + "Please fix the validation errors", + { category_id: ["Category is required for public competition props"] }, + ERROR_CODES.VALIDATION_ERROR, + ); + } + // For private competitions, only admins can create props if (competition.is_private) { const membership = await trx diff --git a/migrations/1773712944723_relax-prop-category-constraint.ts b/migrations/1773712944723_relax-prop-category-constraint.ts new file mode 100644 index 00000000..01788b01 --- /dev/null +++ b/migrations/1773712944723_relax-prop-category-constraint.ts @@ -0,0 +1,33 @@ +import type { Kysely } from "kysely"; +import { sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // Drop the old constraint that required user_id OR category_id. + await db.schema + .alterTable("props") + .dropConstraint("at_least_one_of_user_id_and_category_id") + .execute(); + // Add a relaxed constraint: competition props are allowed to have no category. + // Application-level validation still enforces category for public competition props. + await db.schema + .alterTable("props") + .addCheckConstraint( + "at_least_one_of_user_id_category_id_competition_id", + sql`user_id IS NOT NULL OR category_id IS NOT NULL OR competition_id IS NOT NULL`, + ) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema + .alterTable("props") + .dropConstraint("at_least_one_of_user_id_category_id_competition_id") + .execute(); + await db.schema + .alterTable("props") + .addCheckConstraint( + "at_least_one_of_user_id_and_category_id", + sql`user_id IS NOT NULL OR category_id IS NOT NULL`, + ) + .execute(); +}