From d4f6da98860d93641d73e963ca13d1411395ef98 Mon Sep 17 00:00:00 2001 From: Evan Petzoldt Date: Tue, 24 Feb 2026 07:51:30 -0600 Subject: [PATCH] added routes for event instance, attendance, and event tags --- apps/api/src/app/docs/openapi.json/route.ts | 12 + packages/api/src/index.ts | 6 + packages/api/src/lib/webhook-events.test.ts | 10 + packages/api/src/router/attendance.test.ts | 785 ++++++++++++ packages/api/src/router/attendance.ts | 761 ++++++++++++ .../api/src/router/event-instance.test.ts | 726 +++++++++++ packages/api/src/router/event-instance.ts | 1089 +++++++++++++++++ packages/api/src/router/event-tag.test.ts | 532 ++++++++ packages/api/src/router/event-tag.ts | 308 +++++ packages/api/src/router/org.test.ts | 20 + packages/validators/src/index.ts | 98 +- 11 files changed, 4340 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/router/attendance.test.ts create mode 100644 packages/api/src/router/attendance.ts create mode 100644 packages/api/src/router/event-instance.test.ts create mode 100644 packages/api/src/router/event-instance.ts create mode 100644 packages/api/src/router/event-tag.test.ts create mode 100644 packages/api/src/router/event-tag.ts diff --git a/apps/api/src/app/docs/openapi.json/route.ts b/apps/api/src/app/docs/openapi.json/route.ts index cdea8093..07421c7b 100644 --- a/apps/api/src/app/docs/openapi.json/route.ts +++ b/apps/api/src/app/docs/openapi.json/route.ts @@ -132,7 +132,10 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y tags: [ "api-key", "event", + "event-instance", "event-type", + "event-tag", + "attendance", "location", "org", "ping", @@ -156,7 +159,16 @@ As of February 1, 2026, regional admins can only create read-only API keys. If y description: "API key management for programmatic access", }, { name: "event", description: "Workout event management" }, + { + name: "event-instance", + description: "Specific occurrences of workout events", + }, { name: "event-type", description: "Event type/category management" }, + { + name: "event-tag", + description: "Event tag management for categorization", + }, + { name: "attendance", description: "Attendance management for events" }, { name: "location", description: "Physical location management for workouts", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 1a635e9e..1844c161 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,7 +3,10 @@ import { os } from "@orpc/server"; import { API_PREFIX_V1 } from "@acme/shared/app/constants"; import { apiKeyRouter } from "./router/api-key"; +import { attendanceRouter } from "./router/attendance"; import { eventRouter } from "./router/event"; +import { eventInstanceRouter } from "./router/event-instance"; +import { eventTagRouter } from "./router/event-tag"; import { eventTypeRouter } from "./router/event-type"; import { locationRouter } from "./router/location"; import { mailRouter } from "./router/mail"; @@ -21,7 +24,10 @@ export type { WebhookEvent } from "./lib/webhook-events"; export const router = os.prefix(API_PREFIX_V1).router({ apiKey: os.prefix("/api-key").router(apiKeyRouter), + attendance: os.prefix("/attendance").router(attendanceRouter), event: os.prefix("/event").router(eventRouter), + eventInstance: os.prefix("/event-instance").router(eventInstanceRouter), + eventTag: os.prefix("/event-tag").router(eventTagRouter), eventType: os.prefix("/event-type").router(eventTypeRouter), mail: os.prefix("/mail").router(mailRouter), ping: os.router(pingRouter), diff --git a/packages/api/src/lib/webhook-events.test.ts b/packages/api/src/lib/webhook-events.test.ts index c2dcddf0..1ebe16e1 100644 --- a/packages/api/src/lib/webhook-events.test.ts +++ b/packages/api/src/lib/webhook-events.test.ts @@ -476,6 +476,11 @@ describe("Webhook Events", () => { parentId: region.id, isActive: true, email: null, + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }); if (result.org) { @@ -516,6 +521,11 @@ describe("Webhook Events", () => { parentId: region.id, isActive: true, email: null, + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }); // Verify notifyWebhooks was called with correct payload diff --git a/packages/api/src/router/attendance.test.ts b/packages/api/src/router/attendance.test.ts new file mode 100644 index 00000000..f37d7b13 --- /dev/null +++ b/packages/api/src/router/attendance.test.ts @@ -0,0 +1,785 @@ +/** + * Tests for Attendance Router endpoints + * + * These tests require: + * - TEST_DATABASE_URL environment variable to be set + * - Test database to be seeded with test data + */ + +import { vi } from "vitest"; + +// Use vi.hoisted to ensure mockLimit is available when vi.mock runs (mocks are hoisted) +const mockLimit = vi.hoisted(() => vi.fn()); + +vi.mock("@orpc/experimental-ratelimit/memory", () => ({ + MemoryRatelimiter: vi.fn().mockImplementation(() => ({ + limit: mockLimit, + })), +})); + +import { and, eq, schema } from "@acme/db"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + cleanup, + createAdminSession, + createTestClient, + db, + getOrCreateF3NationOrg, + mockAuthWithSession, + uniqueId, +} from "../__tests__/test-utils"; + +/** + * Store attendance type IDs fetched/created during setup + */ +let ATTENDANCE_TYPE_IDS: { + PAX: number; + Q: number; + COQ: number; +} | null = null; + +/** + * Get or create required attendance types for testing + */ +const getOrCreateAttendanceTypes = async () => { + if (ATTENDANCE_TYPE_IDS) return ATTENDANCE_TYPE_IDS; + + // Check for existing PAX type + let [paxType] = await db + .select() + .from(schema.attendanceTypes) + .where(eq(schema.attendanceTypes.type, "PAX")); + + if (!paxType) { + [paxType] = await db + .insert(schema.attendanceTypes) + .values({ type: "PAX", description: "Regular attendee" }) + .returning(); + } + + // Check for existing Q type + let [qType] = await db + .select() + .from(schema.attendanceTypes) + .where(eq(schema.attendanceTypes.type, "Q")); + + if (!qType) { + [qType] = await db + .insert(schema.attendanceTypes) + .values({ type: "Q", description: "Workout leader" }) + .returning(); + } + + // Check for existing Co-Q type + let [coQType] = await db + .select() + .from(schema.attendanceTypes) + .where(eq(schema.attendanceTypes.type, "Co-Q")); + + if (!coQType) { + [coQType] = await db + .insert(schema.attendanceTypes) + .values({ type: "Co-Q", description: "Co-leader" }) + .returning(); + } + + ATTENDANCE_TYPE_IDS = { + PAX: paxType!.id, + Q: qType!.id, + COQ: coQType!.id, + }; + + return ATTENDANCE_TYPE_IDS; +}; + +describe("Attendance Router", () => { + // Track created resources for cleanup + const createdEventInstanceIds: number[] = []; + const createdOrgIds: number[] = []; + const createdUserIds: number[] = []; + const createdAttendanceIds: number[] = []; + + // Set up attendance types before all tests + beforeAll(async () => { + await getOrCreateAttendanceTypes(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + // Reset rate limiter to allow requests + mockLimit.mockResolvedValue({ + success: true, + limit: 10, + remaining: 9, + reset: Date.now() + 60000, + }); + }); + + afterAll(async () => { + // Clean up in reverse order respecting FK constraints + for (const attendanceId of createdAttendanceIds.reverse()) { + try { + await db + .delete(schema.attendanceXAttendanceTypes) + .where( + eq(schema.attendanceXAttendanceTypes.attendanceId, attendanceId), + ); + await db + .delete(schema.attendance) + .where(eq(schema.attendance.id, attendanceId)); + } catch { + // Ignore errors during cleanup + } + } + for (const eventInstanceId of createdEventInstanceIds.reverse()) { + try { + await db + .delete(schema.eventInstances) + .where(eq(schema.eventInstances.id, eventInstanceId)); + } catch { + // Ignore errors during cleanup + } + } + for (const userId of createdUserIds.reverse()) { + try { + await cleanup.user(userId); + } catch { + // Ignore errors during cleanup + } + } + for (const orgId of createdOrgIds.reverse()) { + try { + await cleanup.org(orgId); + } catch { + // Ignore errors during cleanup + } + } + }, 30000); // 30 second timeout for cleanup + + // Helper to create a test AO org with region parent + const createTestAO = async () => { + const nationOrg = await getOrCreateF3NationOrg(); + const [region] = await db + .insert(schema.orgs) + .values({ + name: `Test Region ${uniqueId()}`, + orgType: "region", + parentId: nationOrg.id, + isActive: true, + }) + .returning(); + + if (region) { + createdOrgIds.push(region.id); + } + + const [ao] = await db + .insert(schema.orgs) + .values({ + name: `Test AO ${uniqueId()}`, + orgType: "ao", + parentId: region?.id, + isActive: true, + }) + .returning(); + + if (ao) { + createdOrgIds.push(ao.id); + } + + return { region, ao }; + }; + + // Helper to create a test event instance + const createTestEventInstance = async (orgId: number) => { + const [eventInstance] = await db + .insert(schema.eventInstances) + .values({ + name: `Test Event ${uniqueId()}`, + orgId, + startDate: new Date().toISOString().split("T")[0]!, + isActive: true, + highlight: false, + }) + .returning(); + + if (eventInstance) { + createdEventInstanceIds.push(eventInstance.id); + } + + return eventInstance; + }; + + // Helper to create a test user + const createTestUser = async () => { + const [user] = await db + .insert(schema.users) + .values({ + email: `test-${uniqueId()}@example.com`, + f3Name: `TestUser ${uniqueId()}`, + }) + .returning(); + + if (user) { + createdUserIds.push(user.id); + } + + return user; + }; + + describe("getForEventInstance", () => { + it("should return empty attendance for new event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.attendance.getForEventInstance({ + eventInstanceId: eventInstance.id, + isPlanned: true, + }); + + expect(result).toHaveProperty("attendance"); + expect(Array.isArray(result.attendance)).toBe(true); + expect(result.attendance.length).toBe(0); + }); + + it("should return attendance records with user info", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + // Create attendance record + const [attendance] = await db + .insert(schema.attendance) + .values({ + eventInstanceId: eventInstance.id, + userId: user.id, + isPlanned: true, + }) + .returning(); + + if (attendance) { + createdAttendanceIds.push(attendance.id); + } + + const client = createTestClient(); + const result = await client.attendance.getForEventInstance({ + eventInstanceId: eventInstance.id, + isPlanned: true, + }); + + expect(result.attendance.length).toBe(1); + expect(result.attendance[0]?.userId).toBe(user.id); + expect(result.attendance[0]?.user?.f3Name).toBe(user.f3Name); + }); + + it("should filter by isPlanned flag", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + // Create planned attendance + const [plannedAttendance] = await db + .insert(schema.attendance) + .values({ + eventInstanceId: eventInstance.id, + userId: user.id, + isPlanned: true, + }) + .returning(); + + if (plannedAttendance) { + createdAttendanceIds.push(plannedAttendance.id); + } + + const client = createTestClient(); + + // Get planned attendance + const plannedResult = await client.attendance.getForEventInstance({ + eventInstanceId: eventInstance.id, + isPlanned: true, + }); + expect(plannedResult.attendance.length).toBe(1); + + // Get actual attendance (should be empty) + const actualResult = await client.attendance.getForEventInstance({ + eventInstanceId: eventInstance.id, + isPlanned: false, + }); + expect(actualResult.attendance.length).toBe(0); + }); + }); + + describe("createPlanned", () => { + it("should create planned attendance for a user", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + const result = await client.attendance.createPlanned({ + eventInstanceId: eventInstance.id, + userId: user.id, + attendanceTypeIds: [ATTENDANCE_TYPE_IDS!.PAX], // PAX attendance type + }); + + expect(result.success).toBe(true); + expect(result.attendanceId).toBeDefined(); + + if (result.attendanceId) { + createdAttendanceIds.push(result.attendanceId); + } + + // Verify attendance was created + const [created] = await db + .select() + .from(schema.attendance) + .where(eq(schema.attendance.id, result.attendanceId)); + + expect(created).toBeDefined(); + expect(created?.userId).toBe(user.id); + expect(created?.isPlanned).toBe(true); + }); + + it("should update existing planned attendance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + + // Create first attendance with PAX type + const result1 = await client.attendance.createPlanned({ + eventInstanceId: eventInstance.id, + userId: user.id, + attendanceTypeIds: [ATTENDANCE_TYPE_IDS!.PAX], // PAX + }); + + if (result1.attendanceId) { + createdAttendanceIds.push(result1.attendanceId); + } + + // Update with Q type + const result2 = await client.attendance.createPlanned({ + eventInstanceId: eventInstance.id, + userId: user.id, + attendanceTypeIds: [ATTENDANCE_TYPE_IDS!.PAX, ATTENDANCE_TYPE_IDS!.Q], // PAX + Q + }); + + // Should return same attendance ID + expect(result2.attendanceId).toBe(result1.attendanceId); + }); + + it("should throw NOT_FOUND for non-existent event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + + await expect( + client.attendance.createPlanned({ + eventInstanceId: 999999, + userId: user.id, + attendanceTypeIds: [ATTENDANCE_TYPE_IDS!.PAX], + }), + ).rejects.toThrow(); + }); + }); + + describe("createActual", () => { + it("should create actual attendance for backblast", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + const result = await client.attendance.createActual({ + eventInstanceId: eventInstance.id, + userId: user.id, + attendanceTypeIds: [ATTENDANCE_TYPE_IDS!.PAX], // PAX + }); + + expect(result.success).toBe(true); + expect(result.attendanceId).toBeDefined(); + + if (result.attendanceId) { + createdAttendanceIds.push(result.attendanceId); + } + + // Verify attendance was created with isPlanned=false + const [created] = await db + .select() + .from(schema.attendance) + .where(eq(schema.attendance.id, result.attendanceId)); + + expect(created?.isPlanned).toBe(false); + }); + }); + + describe("removePlanned", () => { + it("should remove planned attendance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + // Create attendance + const [attendance] = await db + .insert(schema.attendance) + .values({ + eventInstanceId: eventInstance.id, + userId: user.id, + isPlanned: true, + }) + .returning(); + + if (attendance) { + createdAttendanceIds.push(attendance.id); + } + + const client = createTestClient(); + const result = await client.attendance.removePlanned({ + eventInstanceId: eventInstance.id, + userId: user.id, + }); + + expect(result.success).toBe(true); + + // Verify attendance was deleted + const remaining = await db + .select() + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, eventInstance.id), + eq(schema.attendance.userId, user.id), + eq(schema.attendance.isPlanned, true), + ), + ); + + expect(remaining.length).toBe(0); + }); + }); + + describe("takeQ", () => { + it("should assign Q to a user", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + const result = await client.attendance.takeQ({ + eventInstanceId: eventInstance.id, + userId: user.id, + }); + + expect(result.success).toBe(true); + expect(result.attendanceId).toBeDefined(); + + if (result.attendanceId) { + createdAttendanceIds.push(result.attendanceId); + } + + // Verify Q attendance type was created + const attendanceTypes = await db + .select() + .from(schema.attendanceXAttendanceTypes) + .where( + eq( + schema.attendanceXAttendanceTypes.attendanceId, + result.attendanceId, + ), + ); + + // Should have both PAX (1) and Q (2) types + const typeIds = attendanceTypes.map((at) => at.attendanceTypeId); + expect(typeIds).toContain(ATTENDANCE_TYPE_IDS!.PAX); // PAX + expect(typeIds).toContain(ATTENDANCE_TYPE_IDS!.Q); // Q + }); + + it("should throw CONFLICT when Q already assigned to another user", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user1 = await createTestUser(); + const user2 = await createTestUser(); + if (!user1 || !user2) return; + + const client = createTestClient(); + + // User1 takes Q + const result1 = await client.attendance.takeQ({ + eventInstanceId: eventInstance.id, + userId: user1.id, + }); + + if (result1.attendanceId) { + createdAttendanceIds.push(result1.attendanceId); + } + + // User2 tries to take Q - should fail + await expect( + client.attendance.takeQ({ + eventInstanceId: eventInstance.id, + userId: user2.id, + }), + ).rejects.toThrow(); + }); + }); + + describe("removeQ", () => { + it("should remove Q status from user", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user = await createTestUser(); + if (!user) return; + + const client = createTestClient(); + + // Take Q first + const takeResult = await client.attendance.takeQ({ + eventInstanceId: eventInstance.id, + userId: user.id, + }); + + if (takeResult.attendanceId) { + createdAttendanceIds.push(takeResult.attendanceId); + } + + // Remove Q + const removeResult = await client.attendance.removeQ({ + eventInstanceId: eventInstance.id, + userId: user.id, + }); + + expect(removeResult.success).toBe(true); + + // Verify Q type was removed (should only have PAX left) + const attendanceTypes = await db + .select() + .from(schema.attendanceXAttendanceTypes) + .where( + eq( + schema.attendanceXAttendanceTypes.attendanceId, + takeResult.attendanceId, + ), + ); + + const typeIds = attendanceTypes.map((at) => at.attendanceTypeId); + expect(typeIds).not.toContain(ATTENDANCE_TYPE_IDS!.Q); // Q should be removed + }); + }); + + describe("assignQAndCoQs", () => { + it("should assign Q and Co-Qs to an event", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const qUser = await createTestUser(); + const coQUser = await createTestUser(); + if (!qUser || !coQUser) return; + + const client = createTestClient(); + const result = await client.attendance.assignQAndCoQs({ + eventInstanceId: eventInstance.id, + qUserId: qUser.id, + coQUserIds: [coQUser.id], + }); + + expect(result.success).toBe(true); + + // Verify Q and Co-Q were assigned + const attendance = await db + .select({ + id: schema.attendance.id, + userId: schema.attendance.userId, + }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, eventInstance.id), + eq(schema.attendance.isPlanned, true), + ), + ); + + // Track for cleanup + attendance.forEach((a) => createdAttendanceIds.push(a.id)); + + expect(attendance.length).toBe(2); + + // Verify attendance types + const qAttendance = attendance.find((a) => a.userId === qUser.id); + const coQAttendance = attendance.find((a) => a.userId === coQUser.id); + + expect(qAttendance).toBeDefined(); + expect(coQAttendance).toBeDefined(); + + if (qAttendance) { + const qTypes = await db + .select() + .from(schema.attendanceXAttendanceTypes) + .where( + eq(schema.attendanceXAttendanceTypes.attendanceId, qAttendance.id), + ); + const qTypeIds = qTypes.map((t) => t.attendanceTypeId); + expect(qTypeIds).toContain(ATTENDANCE_TYPE_IDS!.Q); // Q type + } + + if (coQAttendance) { + const coQTypes = await db + .select() + .from(schema.attendanceXAttendanceTypes) + .where( + eq( + schema.attendanceXAttendanceTypes.attendanceId, + coQAttendance.id, + ), + ); + const coQTypeIds = coQTypes.map((t) => t.attendanceTypeId); + expect(coQTypeIds).toContain(ATTENDANCE_TYPE_IDS!.COQ); // Co-Q type + } + }); + }); + + describe("deleteActualForEvent", () => { + it("should delete all actual attendance for an event", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const { ao } = await createTestAO(); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const user1 = await createTestUser(); + const user2 = await createTestUser(); + if (!user1 || !user2) return; + + // Create actual attendance for both users + await db + .insert(schema.attendance) + .values({ + eventInstanceId: eventInstance.id, + userId: user1.id, + isPlanned: false, + }) + .returning(); + + await db + .insert(schema.attendance) + .values({ + eventInstanceId: eventInstance.id, + userId: user2.id, + isPlanned: false, + }) + .returning(); + + // Don't add to cleanup since we're deleting them + + const client = createTestClient(); + const result = await client.attendance.deleteActualForEvent({ + eventInstanceId: eventInstance.id, + }); + + expect(result.success).toBe(true); + expect(result.deletedCount).toBe(2); + + // Verify deletion + const remaining = await db + .select() + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, eventInstance.id), + eq(schema.attendance.isPlanned, false), + ), + ); + + expect(remaining.length).toBe(0); + }); + }); +}); diff --git a/packages/api/src/router/attendance.ts b/packages/api/src/router/attendance.ts new file mode 100644 index 00000000..c7997b69 --- /dev/null +++ b/packages/api/src/router/attendance.ts @@ -0,0 +1,761 @@ +import { ORPCError } from "@orpc/server"; +import { z } from "zod"; + +import { and, eq, inArray, schema, sql } from "@acme/db"; +import type { AppDb } from "@acme/db/client"; + +import { protectedProcedure } from "../shared"; + +/** + * Get attendance type IDs by type name from the database + */ +async function getAttendanceTypeIds(db: AppDb) { + const types = await db + .select({ + id: schema.attendanceTypes.id, + type: schema.attendanceTypes.type, + }) + .from(schema.attendanceTypes); + + const typeMap = Object.fromEntries( + types.map((t) => [t.type, t.id]), + ) as Record; + + return { + PAX: typeMap.PAX ?? 0, + Q: typeMap.Q ?? 0, + COQ: typeMap["Co-Q"] ?? 0, + }; +} + +/** + * Attendance Router + * Manages attendance records for event instances. + * Supports HC/Q/Co-Q operations for preblasts and backblasts. + */ +export const attendanceRouter = { + /** + * Get all attendance records for an event instance + * Includes user info and attendance types + */ + getForEventInstance: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + // Use transform to properly handle string "false" from query params + // z.coerce.boolean() treats any non-empty string as true + isPlanned: z + .union([z.boolean(), z.string()]) + .optional() + .default(true) + .transform((val) => { + if (typeof val === "boolean") return val; + if (val === "false" || val === "0") return false; + return true; + }), + }), + ) + .route({ + method: "GET", + path: "/event-instance/{eventInstanceId}", + tags: ["attendance"], + summary: "Get attendance for event instance", + description: + "Get all attendance records for an event instance with user info and types", + }) + .handler(async ({ context: ctx, input }) => { + // Get attendance records with user info + const attendanceRecords = await ctx.db + .select({ + id: schema.attendance.id, + userId: schema.attendance.userId, + eventInstanceId: schema.attendance.eventInstanceId, + isPlanned: schema.attendance.isPlanned, + meta: schema.attendance.meta, + created: schema.attendance.created, + user: { + id: schema.users.id, + f3Name: schema.users.f3Name, + email: schema.users.email, + }, + attendanceTypes: sql<{ id: number; type: string }[]>`COALESCE( + json_agg( + DISTINCT jsonb_build_object( + 'id', ${schema.attendanceTypes.id}, + 'type', ${schema.attendanceTypes.type} + ) + ) + FILTER ( + WHERE ${schema.attendanceTypes.id} IS NOT NULL + ), + '[]' + )`, + }) + .from(schema.attendance) + .leftJoin(schema.users, eq(schema.users.id, schema.attendance.userId)) + .leftJoin( + schema.attendanceXAttendanceTypes, + eq( + schema.attendanceXAttendanceTypes.attendanceId, + schema.attendance.id, + ), + ) + .leftJoin( + schema.attendanceTypes, + eq( + schema.attendanceTypes.id, + schema.attendanceXAttendanceTypes.attendanceTypeId, + ), + ) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.isPlanned, input.isPlanned), + ), + ) + .groupBy( + schema.attendance.id, + schema.users.id, + schema.users.f3Name, + schema.users.email, + ); + + return { attendance: attendanceRecords }; + }), + + /** + * Create planned attendance for a user on an event instance + * Used for HC/Q/Co-Q sign-ups from preblast + */ + createPlanned: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + userId: z.coerce.number(), + attendanceTypeIds: z.array(z.coerce.number()), + }), + ) + .route({ + method: "POST", + path: "/", + tags: ["attendance"], + summary: "Create planned attendance", + description: + "Create a new planned attendance record with specified attendance types", + }) + .handler(async ({ context: ctx, input }) => { + // Verify event instance exists and get orgId + const [eventInstance] = await ctx.db + .select({ orgId: schema.eventInstances.orgId }) + .from(schema.eventInstances) + .where(eq(schema.eventInstances.id, input.eventInstanceId)); + + if (!eventInstance) { + throw new ORPCError("NOT_FOUND", { + message: "Event instance not found", + }); + } + + // Check if user already has attendance for this event + const existingAttendance = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + ), + ); + + let attendanceId: number; + + if (existingAttendance.length > 0) { + // Update existing attendance - clear old types and add new ones + attendanceId = existingAttendance[0]!.id; + + // Delete existing attendance type links + await ctx.db + .delete(schema.attendanceXAttendanceTypes) + .where( + eq(schema.attendanceXAttendanceTypes.attendanceId, attendanceId), + ); + } else { + // Create new attendance record + const [newAttendance] = await ctx.db + .insert(schema.attendance) + .values({ + eventInstanceId: input.eventInstanceId, + userId: input.userId, + isPlanned: true, + }) + .returning({ id: schema.attendance.id }); + + if (!newAttendance) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create attendance record", + }); + } + + attendanceId = newAttendance.id; + } + + // Create attendance type links + if (input.attendanceTypeIds.length > 0) { + await ctx.db.insert(schema.attendanceXAttendanceTypes).values( + input.attendanceTypeIds.map((typeId) => ({ + attendanceId, + attendanceTypeId: typeId, + })), + ); + } + + return { success: true, attendanceId }; + }), + + /** + * Create actual (non-planned) attendance for backblast submissions. + * This creates attendance records with isPlanned=false. + */ + createActual: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + userId: z.coerce.number(), + attendanceTypeIds: z.array(z.coerce.number()), + }), + ) + .route({ + method: "POST", + path: "/actual", + tags: ["attendance"], + summary: "Create actual attendance", + description: + "Create a new actual attendance record (for backblast submissions)", + }) + .handler(async ({ context: ctx, input }) => { + // Verify event instance exists + const [eventInstance] = await ctx.db + .select({ id: schema.eventInstances.id }) + .from(schema.eventInstances) + .where(eq(schema.eventInstances.id, input.eventInstanceId)); + + if (!eventInstance) { + throw new ORPCError("NOT_FOUND", { + message: "Event instance not found", + }); + } + + // Check if user already has actual attendance for this event + const existingAttendance = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, false), + ), + ); + + let attendanceId: number; + + if (existingAttendance.length > 0) { + // Update existing attendance - clear old types and add new ones + attendanceId = existingAttendance[0]!.id; + + // Delete existing attendance type links + await ctx.db + .delete(schema.attendanceXAttendanceTypes) + .where( + eq(schema.attendanceXAttendanceTypes.attendanceId, attendanceId), + ); + } else { + // Create new attendance record with isPlanned=false + const [newAttendance] = await ctx.db + .insert(schema.attendance) + .values({ + eventInstanceId: input.eventInstanceId, + userId: input.userId, + isPlanned: false, + }) + .returning({ id: schema.attendance.id }); + + if (!newAttendance) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create attendance record", + }); + } + + attendanceId = newAttendance.id; + } + + // Create attendance type links + if (input.attendanceTypeIds.length > 0) { + await ctx.db.insert(schema.attendanceXAttendanceTypes).values( + input.attendanceTypeIds.map((typeId) => ({ + attendanceId, + attendanceTypeId: typeId, + })), + ); + } + + return { success: true, attendanceId }; + }), + + /** + * Delete all actual (non-planned) attendance for an event instance. + * Used before re-submitting a backblast. + */ + deleteActualForEvent: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + }), + ) + .route({ + method: "DELETE", + path: "/event-instance/{eventInstanceId}/actual", + tags: ["attendance"], + summary: "Delete actual attendance for event", + description: + "Delete all actual attendance records for an event (for backblast re-submission)", + }) + .handler(async ({ context: ctx, input }) => { + // Get all actual attendance IDs for this event + const actualAttendance = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.isPlanned, false), + ), + ); + + if (actualAttendance.length === 0) { + return { success: true, deletedCount: 0 }; + } + + const attendanceIds = actualAttendance.map((a) => a.id); + + // Delete attendance type links first + await ctx.db + .delete(schema.attendanceXAttendanceTypes) + .where( + inArray( + schema.attendanceXAttendanceTypes.attendanceId, + attendanceIds, + ), + ); + + // Delete attendance records + const deleted = await ctx.db + .delete(schema.attendance) + .where(inArray(schema.attendance.id, attendanceIds)) + .returning({ id: schema.attendance.id }); + + return { + success: true, + deletedCount: deleted.length, + }; + }), + + /** + * Remove planned attendance for a user on an event instance + */ + removePlanned: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + userId: z.coerce.number(), + }), + ) + .route({ + method: "DELETE", + path: "/event-instance/{eventInstanceId}/user/{userId}", + tags: ["attendance"], + summary: "Remove planned attendance", + description: "Remove planned attendance record for a user", + }) + .handler(async ({ context: ctx, input }) => { + // Find and delete the attendance record + const deleted = await ctx.db + .delete(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + ), + ) + .returning({ id: schema.attendance.id }); + + return { + success: true, + deletedCount: deleted.length, + }; + }), + + /** + * Update attendance types for an existing attendance record + * Used for changing Q/Co-Q status without removing HC + */ + updateAttendanceTypes: protectedProcedure + .input( + z.object({ + attendanceId: z.coerce.number(), + attendanceTypeIds: z.array(z.coerce.number()), + }), + ) + .route({ + method: "PATCH", + path: "/{attendanceId}/types", + tags: ["attendance"], + summary: "Update attendance types", + description: + "Update the attendance types for an existing attendance record", + }) + .handler(async ({ context: ctx, input }) => { + // Verify attendance exists + const [attendance] = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where(eq(schema.attendance.id, input.attendanceId)); + + if (!attendance) { + throw new ORPCError("NOT_FOUND", { + message: "Attendance record not found", + }); + } + + // Delete existing type links + await ctx.db + .delete(schema.attendanceXAttendanceTypes) + .where( + eq( + schema.attendanceXAttendanceTypes.attendanceId, + input.attendanceId, + ), + ); + + // Create new type links + if (input.attendanceTypeIds.length > 0) { + await ctx.db.insert(schema.attendanceXAttendanceTypes).values( + input.attendanceTypeIds.map((typeId) => ({ + attendanceId: input.attendanceId, + attendanceTypeId: typeId, + })), + ); + } + + return { success: true }; + }), + + /** + * Take Q for an event - adds user as Q (primary leader) + * Convenience endpoint that handles Q attendance type specifically + */ + takeQ: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + userId: z.coerce.number(), + }), + ) + .route({ + method: "POST", + path: "/take-q", + tags: ["attendance"], + summary: "Take Q for event", + description: "Sign up as Q (primary workout leader) for an event", + }) + .handler(async ({ context: ctx, input }) => { + const ATTENDANCE_TYPE_IDS = await getAttendanceTypeIds(ctx.db); + + // Check if there's already a Q for this event + const existingQ = await ctx.db + .select({ id: schema.attendance.id, userId: schema.attendance.userId }) + .from(schema.attendance) + .innerJoin( + schema.attendanceXAttendanceTypes, + eq( + schema.attendanceXAttendanceTypes.attendanceId, + schema.attendance.id, + ), + ) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.isPlanned, true), + eq( + schema.attendanceXAttendanceTypes.attendanceTypeId, + ATTENDANCE_TYPE_IDS.Q, + ), + ), + ); + + if (existingQ.length > 0 && existingQ[0]?.userId !== input.userId) { + throw new ORPCError("CONFLICT", { + message: "Event already has a Q assigned", + }); + } + + // Check if user already has attendance + const existingAttendance = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + ), + ); + + let attendanceId: number; + + if (existingAttendance.length > 0) { + attendanceId = existingAttendance[0]!.id; + + // Add Q type to existing attendance (keep other types) + const existingType = await ctx.db + .select({ id: schema.attendanceXAttendanceTypes.attendanceTypeId }) + .from(schema.attendanceXAttendanceTypes) + .where( + and( + eq(schema.attendanceXAttendanceTypes.attendanceId, attendanceId), + eq( + schema.attendanceXAttendanceTypes.attendanceTypeId, + ATTENDANCE_TYPE_IDS.Q, + ), + ), + ); + + if (existingType.length === 0) { + await ctx.db.insert(schema.attendanceXAttendanceTypes).values({ + attendanceId, + attendanceTypeId: ATTENDANCE_TYPE_IDS.Q, + }); + } + } else { + // Create new attendance with Q type + const [newAttendance] = await ctx.db + .insert(schema.attendance) + .values({ + eventInstanceId: input.eventInstanceId, + userId: input.userId, + isPlanned: true, + }) + .returning({ id: schema.attendance.id }); + + if (!newAttendance) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create attendance record", + }); + } + + attendanceId = newAttendance.id; + + // Add Q and PAX types + await ctx.db.insert(schema.attendanceXAttendanceTypes).values([ + { attendanceId, attendanceTypeId: ATTENDANCE_TYPE_IDS.Q }, + { attendanceId, attendanceTypeId: ATTENDANCE_TYPE_IDS.PAX }, + ]); + } + + return { success: true, attendanceId }; + }), + + /** + * Remove Q status for user on an event + * Keeps the attendance record but removes Q type + */ + removeQ: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + userId: z.coerce.number(), + }), + ) + .route({ + method: "DELETE", + path: "/remove-q", + tags: ["attendance"], + summary: "Remove Q status", + description: "Remove Q status from attendance (keeps HC status)", + }) + .handler(async ({ context: ctx, input }) => { + const ATTENDANCE_TYPE_IDS = await getAttendanceTypeIds(ctx.db); + + // Find the attendance record + const [attendance] = await ctx.db + .select({ id: schema.attendance.id }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, input.eventInstanceId), + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + ), + ); + + if (!attendance) { + throw new ORPCError("NOT_FOUND", { + message: "Attendance record not found", + }); + } + + // Remove Q and Co-Q types + await ctx.db + .delete(schema.attendanceXAttendanceTypes) + .where( + and( + eq(schema.attendanceXAttendanceTypes.attendanceId, attendance.id), + inArray(schema.attendanceXAttendanceTypes.attendanceTypeId, [ + ATTENDANCE_TYPE_IDS.Q, + ATTENDANCE_TYPE_IDS.COQ, + ]), + ), + ); + + return { success: true }; + }), + + /** + * Assign Q and Co-Qs for an event instance + * Replaces existing Q/Co-Q assignments with the provided users. + * Existing Q/Co-Qs are demoted to regular HC (PAX). + */ + assignQAndCoQs: protectedProcedure + .input( + z.object({ + eventInstanceId: z.coerce.number(), + qUserId: z.coerce.number().optional(), + coQUserIds: z.array(z.coerce.number()).optional().default([]), + }), + ) + .route({ + method: "PUT", + path: "/assign-q", + tags: ["attendance"], + summary: "Assign Q and Co-Qs", + description: + "Assign a Q and optional Co-Qs to an event, demoting existing Q/Co-Qs to HC", + }) + .handler(async ({ context: ctx, input }) => { + const ATTENDANCE_TYPE_IDS = await getAttendanceTypeIds(ctx.db); + const { eventInstanceId, qUserId, coQUserIds } = input; + + // Get all existing planned attendance for this event + const existingAttendance = await ctx.db + .select({ + id: schema.attendance.id, + userId: schema.attendance.userId, + }) + .from(schema.attendance) + .where( + and( + eq(schema.attendance.eventInstanceId, eventInstanceId), + eq(schema.attendance.isPlanned, true), + ), + ); + + // Get existing Q/Co-Q type assignments + const existingQCoQAssignments = await ctx.db + .select({ + attendanceId: schema.attendanceXAttendanceTypes.attendanceId, + attendanceTypeId: schema.attendanceXAttendanceTypes.attendanceTypeId, + }) + .from(schema.attendanceXAttendanceTypes) + .where( + and( + inArray( + schema.attendanceXAttendanceTypes.attendanceId, + existingAttendance.map((a) => a.id), + ), + inArray(schema.attendanceXAttendanceTypes.attendanceTypeId, [ + ATTENDANCE_TYPE_IDS.Q, + ATTENDANCE_TYPE_IDS.COQ, + ]), + ), + ); + + // Remove all existing Q/Co-Q type assignments + if (existingQCoQAssignments.length > 0) { + await ctx.db.delete(schema.attendanceXAttendanceTypes).where( + and( + inArray( + schema.attendanceXAttendanceTypes.attendanceId, + existingQCoQAssignments.map((a) => a.attendanceId), + ), + inArray(schema.attendanceXAttendanceTypes.attendanceTypeId, [ + ATTENDANCE_TYPE_IDS.Q, + ATTENDANCE_TYPE_IDS.COQ, + ]), + ), + ); + } + + // Helper to ensure user has attendance record and add type + const ensureAttendanceWithType = async ( + userId: number, + typeId: number, + ) => { + const existing = existingAttendance.find((a) => a.userId === userId); + + if (existing) { + // Add type to existing attendance + await ctx.db + .insert(schema.attendanceXAttendanceTypes) + .values({ + attendanceId: existing.id, + attendanceTypeId: typeId, + }) + .onConflictDoNothing(); + } else { + // Create new attendance with the type + const [newAttendance] = await ctx.db + .insert(schema.attendance) + .values({ + eventInstanceId, + userId, + isPlanned: true, + }) + .returning({ id: schema.attendance.id }); + + if (newAttendance) { + // Add PAX and the specific type + await ctx.db.insert(schema.attendanceXAttendanceTypes).values([ + { + attendanceId: newAttendance.id, + attendanceTypeId: ATTENDANCE_TYPE_IDS.PAX, + }, + { attendanceId: newAttendance.id, attendanceTypeId: typeId }, + ]); + } + } + }; + + // Assign Q + if (qUserId) { + await ensureAttendanceWithType(qUserId, ATTENDANCE_TYPE_IDS.Q); + } + + // Assign Co-Qs + for (const coQUserId of coQUserIds) { + // Don't assign Co-Q to the Q user + if (coQUserId !== qUserId) { + await ensureAttendanceWithType(coQUserId, ATTENDANCE_TYPE_IDS.COQ); + } + } + + // Update event instance timestamp to trigger calendar refresh + await ctx.db + .update(schema.eventInstances) + .set({ updated: new Date().toISOString() }) + .where(eq(schema.eventInstances.id, eventInstanceId)); + + return { success: true }; + }), +}; diff --git a/packages/api/src/router/event-instance.test.ts b/packages/api/src/router/event-instance.test.ts new file mode 100644 index 00000000..1bbeb5a0 --- /dev/null +++ b/packages/api/src/router/event-instance.test.ts @@ -0,0 +1,726 @@ +/** + * Tests for Event Instance Router endpoints + * + * These tests require: + * - TEST_DATABASE_URL environment variable to be set + * - Test database to be seeded with test data + */ + +import { vi } from "vitest"; + +// Use vi.hoisted to ensure mockLimit is available when vi.mock runs (mocks are hoisted) +const mockLimit = vi.hoisted(() => vi.fn()); + +vi.mock("@orpc/experimental-ratelimit/memory", () => ({ + MemoryRatelimiter: vi.fn().mockImplementation(() => ({ + limit: mockLimit, + })), +})); + +import { eq, schema } from "@acme/db"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { + cleanup, + createAdminSession, + createTestClient, + db, + getOrCreateF3NationOrg, + mockAuthWithSession, + uniqueId, +} from "../__tests__/test-utils"; + +describe("Event Instance Router", () => { + // Track created resources for cleanup + const createdEventInstanceIds: number[] = []; + const createdOrgIds: number[] = []; + const createdEventTypeIds: number[] = []; + const createdLocationIds: number[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset rate limiter to allow requests + mockLimit.mockResolvedValue({ + success: true, + limit: 10, + remaining: 9, + reset: Date.now() + 60000, + }); + }); + + afterAll(async () => { + // Clean up in reverse order respecting FK constraints + for (const eventInstanceId of createdEventInstanceIds.reverse()) { + try { + // First delete join table records + await db + .delete(schema.eventInstancesXEventTypes) + .where( + eq( + schema.eventInstancesXEventTypes.eventInstanceId, + eventInstanceId, + ), + ); + await db + .delete(schema.eventTagsXEventInstances) + .where( + eq( + schema.eventTagsXEventInstances.eventInstanceId, + eventInstanceId, + ), + ); + await db + .delete(schema.attendance) + .where(eq(schema.attendance.eventInstanceId, eventInstanceId)); + // Then delete the event instance + await db + .delete(schema.eventInstances) + .where(eq(schema.eventInstances.id, eventInstanceId)); + } catch { + // Ignore errors during cleanup + } + } + for (const eventTypeId of createdEventTypeIds.reverse()) { + try { + await cleanup.eventType(eventTypeId); + } catch { + // Ignore errors during cleanup + } + } + for (const locationId of createdLocationIds.reverse()) { + try { + await cleanup.location(locationId); + } catch { + // Ignore errors during cleanup + } + } + for (const orgId of createdOrgIds.reverse()) { + try { + await cleanup.org(orgId); + } catch { + // Ignore errors during cleanup + } + } + }, 30000); // 30 second timeout for cleanup + + // Helper to create a test region + const createTestRegion = async () => { + const nationOrg = await getOrCreateF3NationOrg(); + const [region] = await db + .insert(schema.orgs) + .values({ + name: `Test Region ${uniqueId()}`, + orgType: "region", + parentId: nationOrg.id, + isActive: true, + }) + .returning(); + + if (region) { + createdOrgIds.push(region.id); + } + return region; + }; + + // Helper to create a test AO under a region + const createTestAO = async (regionId: number) => { + const [ao] = await db + .insert(schema.orgs) + .values({ + name: `Test AO ${uniqueId()}`, + orgType: "ao", + parentId: regionId, + isActive: true, + }) + .returning(); + + if (ao) { + createdOrgIds.push(ao.id); + } + return ao; + }; + + // Helper to create a test event instance + const createTestEventInstance = async ( + aoId: number, + options?: { name?: string; startDate?: string }, + ) => { + const [eventInstance] = await db + .insert(schema.eventInstances) + .values({ + name: options?.name ?? `Test Event ${uniqueId()}`, + orgId: aoId, + startDate: + options?.startDate ?? new Date().toISOString().split("T")[0]!, + isActive: true, + highlight: false, + }) + .returning(); + + if (eventInstance) { + createdEventInstanceIds.push(eventInstance.id); + } + return eventInstance; + }; + + describe("all", () => { + it("should return a list of event instances", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const result = await client.eventInstance.all({ + pageIndex: 0, + pageSize: 10, + }); + + expect(result).toHaveProperty("eventInstances"); + expect(result).toHaveProperty("totalCount"); + expect(Array.isArray(result.eventInstances)).toBe(true); + }); + + it("should paginate results correctly", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const page1 = await client.eventInstance.all({ + pageIndex: 0, + pageSize: 2, + }); + + const page2 = await client.eventInstance.all({ + pageIndex: 1, + pageSize: 2, + }); + + expect(page1.eventInstances.length).toBeLessThanOrEqual(2); + expect(page2.eventInstances.length).toBeLessThanOrEqual(2); + + // Results should be different if there are more than 2 instances + if ( + page1.totalCount > 2 && + page1.eventInstances.length > 0 && + page2.eventInstances.length > 0 + ) { + expect(page1.eventInstances[0]?.id).not.toBe( + page2.eventInstances[0]?.id, + ); + } + }); + + it("should search by name", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const uniqueName = `SearchableEvent ${uniqueId()}`; + await createTestEventInstance(ao.id, { name: uniqueName }); + + const client = createTestClient(); + const result = await client.eventInstance.all({ + searchTerm: "SearchableEvent", + pageIndex: 0, + pageSize: 10, + }); + + // Results should include our created event instance + const found = result.eventInstances.some((e) => + e.name?.includes("SearchableEvent"), + ); + expect(found).toBe(true); + }); + + it("should filter by AO org", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.eventInstance.all({ + aoOrgId: ao.id, + pageIndex: 0, + pageSize: 10, + }); + + expect(result.eventInstances.length).toBeGreaterThanOrEqual(1); + expect(result.eventInstances.some((e) => e.id === eventInstance.id)).toBe( + true, + ); + }); + + it("should filter by region org", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.eventInstance.all({ + regionOrgId: region.id, + pageIndex: 0, + pageSize: 10, + }); + + // Should include event instances from AOs in this region + expect(result.eventInstances.some((e) => e.id === eventInstance.id)).toBe( + true, + ); + }); + + it("should filter by start date", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 30); + const futureDateStr = futureDate.toISOString().split("T")[0]!; + + const eventInstance = await createTestEventInstance(ao.id, { + startDate: futureDateStr, + }); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.eventInstance.all({ + startDate: futureDateStr, + pageIndex: 0, + pageSize: 10, + }); + + expect(result.eventInstances.some((e) => e.id === eventInstance.id)).toBe( + true, + ); + }); + + it("should filter standalone instances only", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create a standalone event instance (no seriesId) + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.eventInstance.all({ + onlyStandalone: true, + aoOrgId: ao.id, + pageIndex: 0, + pageSize: 10, + }); + + // All returned instances should have no seriesId + expect(result.eventInstances.every((e) => e.seriesId === null)).toBe( + true, + ); + }); + }); + + describe("byId", () => { + it("should return event instance by ID", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const result = await client.eventInstance.byId({ + id: eventInstance.id, + }); + + expect(result).not.toBeNull(); + expect(result?.id).toBe(eventInstance.id); + expect(result?.name).toBe(eventInstance.name); + }); + + it("should return null for non-existent event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const result = await client.eventInstance.byId({ + id: 999999, + }); + + expect(result).toBeNull(); + }); + + it("should include event types and tags in response", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create an event type + const [eventType] = await db + .insert(schema.eventTypes) + .values({ + name: `Test Event Type ${uniqueId()}`, + eventCategory: "first_f", + isActive: true, + }) + .returning(); + + if (eventType) { + createdEventTypeIds.push(eventType.id); + } + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance || !eventType) return; + + // Link event type to event instance + await db.insert(schema.eventInstancesXEventTypes).values({ + eventInstanceId: eventInstance.id, + eventTypeId: eventType.id, + }); + + const client = createTestClient(); + const result = await client.eventInstance.byId({ + id: eventInstance.id, + }); + + expect(result).not.toBeNull(); + expect(result?.eventTypes).toBeDefined(); + expect(Array.isArray(result?.eventTypes)).toBe(true); + }); + }); + + describe("crupdate", () => { + it("should create a new event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const client = createTestClient(); + const eventName = `New Event ${uniqueId()}`; + const startDate = new Date().toISOString().split("T")[0]!; + + const result = await client.eventInstance.crupdate({ + name: eventName, + orgId: ao.id, + startDate, + isActive: true, + }); + + expect(result).toBeDefined(); + expect(result.id).toBeDefined(); + expect(result.name).toBe(eventName); + expect(result.orgId).toBe(ao.id); + + if (result.id) { + createdEventInstanceIds.push(result.id); + } + }); + + it("should update an existing event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + const client = createTestClient(); + const updatedName = `Updated Event ${uniqueId()}`; + + const result = await client.eventInstance.crupdate({ + id: eventInstance.id, + name: updatedName, + orgId: ao.id, + startDate: eventInstance.startDate, + }); + + expect(result.id).toBe(eventInstance.id); + expect(result.name).toBe(updatedName); + }); + + it("should create event instance with event type", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create an event type + const [eventType] = await db + .insert(schema.eventTypes) + .values({ + name: `Test Event Type ${uniqueId()}`, + eventCategory: "first_f", + isActive: true, + }) + .returning(); + + if (eventType) { + createdEventTypeIds.push(eventType.id); + } + + if (!eventType) return; + + const client = createTestClient(); + const result = await client.eventInstance.crupdate({ + name: `Event With Type ${uniqueId()}`, + orgId: ao.id, + startDate: new Date().toISOString().split("T")[0]!, + eventTypeId: eventType.id, + }); + + expect(result).toBeDefined(); + + if (result.id) { + createdEventInstanceIds.push(result.id); + + // Verify event type was linked + const [linkRecord] = await db + .select() + .from(schema.eventInstancesXEventTypes) + .where( + eq(schema.eventInstancesXEventTypes.eventInstanceId, result.id), + ); + + expect(linkRecord).toBeDefined(); + expect(linkRecord?.eventTypeId).toBe(eventType.id); + } + }); + + it("should require editor role", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create session without editor role for this AO + const noPermSession = { + id: 999, + email: "noperm@example.com", + user: { + id: "999", + email: "noperm@example.com", + name: "No Permission User", + roles: [], + }, + roles: [], + expires: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), + }; + await mockAuthWithSession(noPermSession); + + const client = createTestClient(); + + await expect( + client.eventInstance.crupdate({ + name: `Unauthorized Event ${uniqueId()}`, + orgId: ao.id, + startDate: new Date().toISOString().split("T")[0]!, + }), + ).rejects.toThrow(); + }); + }); + + describe("delete", () => { + it("should delete event instance with admin role", async () => { + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create session with admin role on the AO org + const session = { + id: 1, + email: "admin@example.com", + user: { + id: "1", + email: "admin@example.com", + name: "Admin", + roles: [ + { + orgId: ao.id, + orgName: ao.name ?? "Test AO", + roleName: "admin" as const, + }, + ], + }, + roles: [ + { + orgId: ao.id, + orgName: ao.name ?? "Test AO", + roleName: "admin" as const, + }, + ], + expires: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), + }; + await mockAuthWithSession(session); + + const eventInstance = await createTestEventInstance(ao.id); + if (!eventInstance) return; + + // Keep in cleanup list since soft delete still leaves the record + const client = createTestClient(); + const result = await client.eventInstance.delete({ + id: eventInstance.id, + }); + + expect(result.success).toBe(true); + + // Verify soft deletion (isActive = false) + const [deleted] = await db + .select() + .from(schema.eventInstances) + .where(eq(schema.eventInstances.id, eventInstance.id)); + + expect(deleted?.isActive).toBe(false); + }); + + it("should throw NOT_FOUND for non-existent event instance", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + + await expect( + client.eventInstance.delete({ + id: 999999, + }), + ).rejects.toThrow(); + }); + }); + + describe("sorting", () => { + it("should sort by start date ascending", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create events with different dates + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + await createTestEventInstance(ao.id, { + startDate: tomorrow.toISOString().split("T")[0]!, + }); + await createTestEventInstance(ao.id, { + startDate: today.toISOString().split("T")[0]!, + }); + + const client = createTestClient(); + const result = await client.eventInstance.all({ + aoOrgId: ao.id, + sorting: [{ id: "startDate", desc: false }], + pageIndex: 0, + pageSize: 10, + }); + + if (result.eventInstances.length >= 2) { + const dates = result.eventInstances.map((e) => e.startDate); + // Should be sorted ascending + for (let i = 1; i < dates.length; i++) { + expect(dates[i]! >= dates[i - 1]!).toBe(true); + } + } + }); + + it("should sort by start date descending", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const ao = await createTestAO(region.id); + if (!ao) return; + + // Create events with different dates + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + await createTestEventInstance(ao.id, { + startDate: today.toISOString().split("T")[0]!, + }); + await createTestEventInstance(ao.id, { + startDate: tomorrow.toISOString().split("T")[0]!, + }); + + const client = createTestClient(); + const result = await client.eventInstance.all({ + aoOrgId: ao.id, + sorting: [{ id: "startDate", desc: true }], + pageIndex: 0, + pageSize: 10, + }); + + if (result.eventInstances.length >= 2) { + const dates = result.eventInstances.map((e) => e.startDate); + // Should be sorted descending + for (let i = 1; i < dates.length; i++) { + expect(dates[i]! <= dates[i - 1]!).toBe(true); + } + } + }); + }); +}); diff --git a/packages/api/src/router/event-instance.ts b/packages/api/src/router/event-instance.ts new file mode 100644 index 00000000..4753d896 --- /dev/null +++ b/packages/api/src/router/event-instance.ts @@ -0,0 +1,1089 @@ +import { ORPCError } from "@orpc/server"; +import { z } from "zod"; + +import { + aliasedTable, + and, + asc, + count, + desc, + eq, + gte, + ilike, + inArray, + isNull, + or, + schema, + sql, +} from "@acme/db"; +import { arrayOrSingle } from "@acme/shared/app/functions"; + +import { checkHasRoleOnOrg } from "../check-has-role-on-org"; +import { editorProcedure, protectedProcedure } from "../shared"; +import { withPagination } from "../with-pagination"; + +/** + * Event Instance Router + * Event instances are individual event occurrences stored in the event_instances table. + * They may or may not be linked to a series (recurring event) via seriesId. + */ +export const eventInstanceRouter = { + all: protectedProcedure + .input( + z + .object({ + pageIndex: z.coerce.number().optional(), + pageSize: z.coerce.number().optional(), + searchTerm: z.string().optional(), + statuses: arrayOrSingle(z.enum(["active", "inactive"])).optional(), + sorting: z + .array(z.object({ id: z.string(), desc: z.coerce.boolean() })) + .optional(), + regionOrgId: z.coerce.number().optional(), + aoOrgId: z.coerce.number().optional(), + startDate: z.string().optional(), + seriesId: z.coerce.number().optional(), + onlyStandalone: z.coerce.boolean().optional(), // Only instances without a series + }) + .optional(), + ) + .route({ + method: "GET", + path: "/", + tags: ["event-instance"], + summary: "List all event instances", + description: + "Get a paginated list of event instances (individual occurrences)", + }) + .handler(async ({ context: ctx, input }) => { + const regionOrg = aliasedTable(schema.orgs, "region_org"); + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + const limit = input?.pageSize ?? 40; + const offset = (input?.pageIndex ?? 0) * limit; + const usePagination = + input?.pageIndex !== undefined && input?.pageSize !== undefined; + + const where = and( + // Active status filter + input?.statuses?.includes("inactive") + ? undefined + : eq(schema.eventInstances.isActive, true), + // Search filter + input?.searchTerm + ? or( + ilike(schema.eventInstances.name, `%${input.searchTerm}%`), + ilike(schema.eventInstances.description, `%${input.searchTerm}%`), + ) + : undefined, + // Region filter (through AO's parent) + input?.regionOrgId ? eq(regionOrg.id, input.regionOrgId) : undefined, + // AO filter + input?.aoOrgId ? eq(aoOrg.id, input.aoOrgId) : undefined, + // Start date filter + input?.startDate + ? gte(schema.eventInstances.startDate, input.startDate) + : undefined, + // Series filter + input?.seriesId + ? eq(schema.eventInstances.seriesId, input.seriesId) + : undefined, + // Standalone instances only (no series link) + input?.onlyStandalone + ? isNull(schema.eventInstances.seriesId) + : undefined, + ); + + const select = { + id: schema.eventInstances.id, + name: schema.eventInstances.name, + description: schema.eventInstances.description, + isActive: schema.eventInstances.isActive, + locationId: schema.eventInstances.locationId, + orgId: schema.eventInstances.orgId, + seriesId: schema.eventInstances.seriesId, + startDate: schema.eventInstances.startDate, + endDate: schema.eventInstances.endDate, + startTime: schema.eventInstances.startTime, + endTime: schema.eventInstances.endTime, + highlight: schema.eventInstances.highlight, + meta: schema.eventInstances.meta, + isPrivate: schema.eventInstances.isPrivate, + paxCount: schema.eventInstances.paxCount, + fngCount: schema.eventInstances.fngCount, + }; + + const [instanceCount] = await ctx.db + .select({ count: count() }) + .from(schema.eventInstances) + .leftJoin( + aoOrg, + and( + eq(aoOrg.orgType, "ao"), + eq(aoOrg.id, schema.eventInstances.orgId), + ), + ) + .leftJoin( + regionOrg, + and( + eq(regionOrg.orgType, "region"), + eq(regionOrg.id, aoOrg.parentId), + ), + ) + .where(where); + + const sortedColumns = input?.sorting?.map((sorting) => { + const direction = sorting.desc ? desc : asc; + switch (sorting.id) { + case "startDate": + return direction(schema.eventInstances.startDate); + case "startTime": + return direction(schema.eventInstances.startTime); + case "name": + return direction(schema.eventInstances.name); + default: + return direction(schema.eventInstances.startDate); + } + }) ?? [ + asc(schema.eventInstances.startDate), + asc(schema.eventInstances.startTime), + ]; + + const query = ctx.db + .select(select) + .from(schema.eventInstances) + .leftJoin( + aoOrg, + and( + eq(aoOrg.orgType, "ao"), + eq(aoOrg.id, schema.eventInstances.orgId), + ), + ) + .leftJoin( + regionOrg, + and( + eq(regionOrg.orgType, "region"), + eq(regionOrg.id, aoOrg.parentId), + ), + ) + .where(where); + + const instances = usePagination + ? await withPagination(query.$dynamic(), sortedColumns, offset, limit) + : await query.orderBy(...sortedColumns).limit(limit); + + return { + eventInstances: instances, + totalCount: instanceCount?.count ?? 0, + }; + }), + + byId: protectedProcedure + .input(z.object({ id: z.coerce.number() })) + .route({ + method: "GET", + path: "/id/{id}", + tags: ["event-instance"], + summary: "Get event instance by ID", + description: + "Retrieve detailed information about a specific event instance", + }) + .handler(async ({ context: ctx, input }) => { + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + + const [instance] = await ctx.db + .select({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + description: schema.eventInstances.description, + isActive: schema.eventInstances.isActive, + locationId: schema.eventInstances.locationId, + orgId: schema.eventInstances.orgId, + seriesId: schema.eventInstances.seriesId, + startDate: schema.eventInstances.startDate, + endDate: schema.eventInstances.endDate, + startTime: schema.eventInstances.startTime, + endTime: schema.eventInstances.endTime, + highlight: schema.eventInstances.highlight, + meta: schema.eventInstances.meta, + isPrivate: schema.eventInstances.isPrivate, + paxCount: schema.eventInstances.paxCount, + fngCount: schema.eventInstances.fngCount, + preblast: schema.eventInstances.preblast, + preblastRich: schema.eventInstances.preblastRich, + preblastTs: schema.eventInstances.preblastTs, + backblast: schema.eventInstances.backblast, + backblastRich: schema.eventInstances.backblastRich, + backblastTs: schema.eventInstances.backblastTs, + org: sql<{ + id: number; + name: string; + meta: Record | null; + } | null>` + CASE WHEN ${aoOrg.id} IS NOT NULL THEN + jsonb_build_object( + 'id', ${aoOrg.id}, + 'name', ${aoOrg.name}, + 'meta', ${aoOrg.meta} + ) + ELSE NULL END + `, + eventTypes: sql< + { eventTypeId: number; eventTypeName: string }[] + >`COALESCE( + json_agg( + DISTINCT jsonb_build_object( + 'eventTypeId', ${schema.eventTypes.id}, + 'eventTypeName', ${schema.eventTypes.name} + ) + ) + FILTER ( + WHERE ${schema.eventTypes.id} IS NOT NULL + ), + '[]' + )`, + eventTags: sql< + { eventTagId: number; eventTagName: string }[] + >`COALESCE( + json_agg( + DISTINCT jsonb_build_object( + 'eventTagId', ${schema.eventTags.id}, + 'eventTagName', ${schema.eventTags.name} + ) + ) + FILTER ( + WHERE ${schema.eventTags.id} IS NOT NULL + ), + '[]' + )`, + location: sql<{ + id: number; + locationName: string | null; + latitude: number | null; + longitude: number | null; + } | null>` + CASE WHEN ${schema.locations.id} IS NOT NULL THEN + jsonb_build_object( + 'id', ${schema.locations.id}, + 'locationName', ${schema.locations.name}, + 'latitude', ${schema.locations.latitude}, + 'longitude', ${schema.locations.longitude} + ) + ELSE NULL END + `, + }) + .from(schema.eventInstances) + .leftJoin( + aoOrg, + and( + eq(aoOrg.orgType, "ao"), + eq(aoOrg.id, schema.eventInstances.orgId), + ), + ) + .leftJoin( + schema.locations, + eq(schema.locations.id, schema.eventInstances.locationId), + ) + .leftJoin( + schema.eventInstancesXEventTypes, + eq( + schema.eventInstancesXEventTypes.eventInstanceId, + schema.eventInstances.id, + ), + ) + .leftJoin( + schema.eventTypes, + eq( + schema.eventTypes.id, + schema.eventInstancesXEventTypes.eventTypeId, + ), + ) + .leftJoin( + schema.eventTagsXEventInstances, + eq( + schema.eventTagsXEventInstances.eventInstanceId, + schema.eventInstances.id, + ), + ) + .leftJoin( + schema.eventTags, + eq(schema.eventTags.id, schema.eventTagsXEventInstances.eventTagId), + ) + .where(eq(schema.eventInstances.id, input.id)) + .groupBy( + schema.eventInstances.id, + aoOrg.id, + aoOrg.name, + sql`${aoOrg.meta}::text`, + schema.locations.id, + schema.locations.name, + schema.locations.latitude, + schema.locations.longitude, + ); + + return instance ?? null; + }), + + crupdate: editorProcedure + .input( + z.object({ + id: z.coerce.number().optional(), + name: z.string().optional(), + description: z.string().nullish(), + isActive: z.boolean().optional().default(true), + locationId: z.coerce.number().nullish(), + orgId: z.coerce.number(), + seriesId: z.coerce.number().nullish(), // Link to series if this is a series instance + startDate: z.string(), + endDate: z.string().nullish(), + startTime: z.string().nullish(), + endTime: z.string().nullish(), + highlight: z.boolean().optional().default(false), + meta: z.record(z.unknown()).nullish(), + isPrivate: z.boolean().optional().default(false), + eventTypeId: z.coerce.number().optional(), + eventTagId: z.coerce.number().optional(), + preblast: z.string().nullish(), + preblastRich: z.record(z.unknown()).nullish(), + preblastTs: z.number().nullish(), + // Backblast fields + backblast: z.string().nullish(), + backblastRich: z.array(z.record(z.unknown())).nullish(), + backblastTs: z.number().nullish(), + paxCount: z.number().nullish(), + fngCount: z.number().nullish(), + }), + ) + .route({ + method: "POST", + path: "/", + tags: ["event-instance"], + summary: "Create or update event instance", + description: "Create a new event instance or update an existing one", + }) + .handler(async ({ context: ctx, input }) => { + // Check permissions + const roleCheckResult = await checkHasRoleOnOrg({ + orgId: input.orgId, + session: ctx.session, + db: ctx.db, + roleName: "editor", + }); + if (!roleCheckResult.success) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to create/update event instances", + }); + } + + // Generate a default name if not provided or empty + let name = input.name; + if (!name || name.trim() === "") { + // If updating an existing record, preserve the existing name + if (input.id) { + const [existing] = await ctx.db + .select({ name: schema.eventInstances.name }) + .from(schema.eventInstances) + .where(eq(schema.eventInstances.id, input.id)); + if (existing?.name) { + name = existing.name; + } + } + + // If still no name (new record or existing had no name), generate default + if (!name || name.trim() === "") { + // Get AO name + const [ao] = await ctx.db + .select({ name: schema.orgs.name }) + .from(schema.orgs) + .where(eq(schema.orgs.id, input.orgId)); + const aoName = ao?.name ?? "Workout"; + + // Get event type name if provided + let eventTypeName = "Event"; + if (input.eventTypeId) { + const [eventType] = await ctx.db + .select({ name: schema.eventTypes.name }) + .from(schema.eventTypes) + .where(eq(schema.eventTypes.id, input.eventTypeId)); + eventTypeName = eventType?.name ?? "Event"; + } + + name = `${aoName} - ${eventTypeName}`; + } + } + + const { eventTypeId, eventTagId, name: _inputName, ...eventData } = input; + + // Create or update the event instance + const [result] = await ctx.db + .insert(schema.eventInstances) + .values({ + ...eventData, + name, + }) + .onConflictDoUpdate({ + target: [schema.eventInstances.id], + set: { ...eventData, name }, + }) + .returning(); + + if (!result) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Failed to create/update event instance", + }); + } + + // Handle event type in join table + if (eventTypeId) { + await ctx.db + .delete(schema.eventInstancesXEventTypes) + .where( + eq(schema.eventInstancesXEventTypes.eventInstanceId, result.id), + ); + + await ctx.db.insert(schema.eventInstancesXEventTypes).values({ + eventInstanceId: result.id, + eventTypeId, + }); + } + + // Handle event tag in join table + if (eventTagId) { + await ctx.db + .delete(schema.eventTagsXEventInstances) + .where( + eq(schema.eventTagsXEventInstances.eventInstanceId, result.id), + ); + + await ctx.db.insert(schema.eventTagsXEventInstances).values({ + eventInstanceId: result.id, + eventTagId, + }); + } + + return result; + }), + + delete: editorProcedure + .input(z.object({ id: z.coerce.number() })) + .route({ + method: "DELETE", + path: "/id/{id}", + tags: ["event-instance"], + summary: "Delete event instance", + description: "Delete an event instance (hard delete)", + }) + .handler(async ({ context: ctx, input }) => { + const [instance] = await ctx.db + .select() + .from(schema.eventInstances) + .where(eq(schema.eventInstances.id, input.id)); + + if (!instance) { + throw new ORPCError("NOT_FOUND", { + message: "Event instance not found", + }); + } + + const roleCheckResult = await checkHasRoleOnOrg({ + orgId: instance.orgId, + session: ctx.session, + db: ctx.db, + roleName: "admin", + }); + if (!roleCheckResult.success) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to delete this event instance", + }); + } + + // Soft delete for event instances + await ctx.db + .update(schema.eventInstances) + .set({ isActive: false }) + .where(eq(schema.eventInstances.id, input.id)); + + return { success: true }; + }), + + /** + * Get upcoming events where the user is Q or Co-Q + * Used for preblast selection menu + */ + getUpcomingQs: protectedProcedure + .input( + z.object({ + userId: z.coerce.number(), + regionOrgId: z.coerce.number(), + /** Only return events without a posted preblast (preblast_ts IS NULL) */ + notPostedOnly: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .default("true"), + }), + ) + .route({ + method: "GET", + path: "/upcoming-qs", + tags: ["event-instance"], + summary: "Get upcoming events where user is Q/Co-Q", + description: + "Get events where the user has Q or Co-Q attendance for preblast creation", + }) + .handler(async ({ context: ctx, input }) => { + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + + // Attendance type IDs: 2 = Q, 3 = Co-Q + const qAttendanceTypeIds = [2, 3]; + + // Get today's date in YYYY-MM-DD format + const today = new Date().toISOString().split("T")[0]!; + + // Build where conditions + const whereConditions = [ + // Event is active + eq(schema.eventInstances.isActive, true), + // Start date is today or later + gte(schema.eventInstances.startDate, today), + // User has planned attendance + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + // Attendance is Q or Co-Q type + inArray( + schema.attendanceXAttendanceTypes.attendanceTypeId, + qAttendanceTypeIds, + ), + // Event is in the region or a child org of the region + or( + eq(schema.eventInstances.orgId, input.regionOrgId), + eq(aoOrg.parentId, input.regionOrgId), + ), + ]; + + // Add preblast_ts IS NULL filter if notPostedOnly is true + if (input.notPostedOnly) { + whereConditions.push(isNull(schema.eventInstances.preblastTs)); + } + + const instances = await ctx.db + .selectDistinct({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + orgId: schema.eventInstances.orgId, + orgName: aoOrg.name, + locationId: schema.eventInstances.locationId, + seriesId: schema.eventInstances.seriesId, + preblastTs: schema.eventInstances.preblastTs, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .innerJoin( + schema.attendance, + eq(schema.attendance.eventInstanceId, schema.eventInstances.id), + ) + .innerJoin( + schema.attendanceXAttendanceTypes, + eq( + schema.attendanceXAttendanceTypes.attendanceId, + schema.attendance.id, + ), + ) + .where(and(...whereConditions)) + .orderBy( + asc(schema.eventInstances.startDate), + asc(schema.eventInstances.startTime), + ); + + return { eventInstances: instances }; + }), + + /** + * Get past events where the user is Q or Co-Q + * Used for backblast selection menu + */ + getPastQs: protectedProcedure + .input( + z.object({ + userId: z.coerce.number(), + regionOrgId: z.coerce.number(), + /** Only return events without a posted backblast (backblast_ts IS NULL) */ + notPostedOnly: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .default("true"), + }), + ) + .route({ + method: "GET", + path: "/past-qs", + tags: ["event-instance"], + summary: "Get past events where user is Q/Co-Q", + description: + "Get past events where the user has Q or Co-Q attendance for backblast creation", + }) + .handler(async ({ context: ctx, input }) => { + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + + // Attendance type IDs: 2 = Q, 3 = Co-Q + const qAttendanceTypeIds = [2, 3]; + + // Get today's date in YYYY-MM-DD format + const today = new Date().toISOString().split("T")[0]!; + + // Build where conditions + const whereConditions = [ + // Event is active + eq(schema.eventInstances.isActive, true), + // Start date is today or earlier (past events) + sql`${schema.eventInstances.startDate} <= ${today}`, + // User has planned attendance + eq(schema.attendance.userId, input.userId), + eq(schema.attendance.isPlanned, true), + // Attendance is Q or Co-Q type + inArray( + schema.attendanceXAttendanceTypes.attendanceTypeId, + qAttendanceTypeIds, + ), + // Event is in the region or a child org of the region + or( + eq(schema.eventInstances.orgId, input.regionOrgId), + eq(aoOrg.parentId, input.regionOrgId), + ), + ]; + + // Add backblast_ts IS NULL filter if notPostedOnly is true + if (input.notPostedOnly) { + whereConditions.push(isNull(schema.eventInstances.backblastTs)); + } + + const instances = await ctx.db + .selectDistinct({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + orgId: schema.eventInstances.orgId, + orgName: aoOrg.name, + locationId: schema.eventInstances.locationId, + seriesId: schema.eventInstances.seriesId, + backblastTs: schema.eventInstances.backblastTs, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .innerJoin( + schema.attendance, + eq(schema.attendance.eventInstanceId, schema.eventInstances.id), + ) + .innerJoin( + schema.attendanceXAttendanceTypes, + eq( + schema.attendanceXAttendanceTypes.attendanceId, + schema.attendance.id, + ), + ) + .where(and(...whereConditions)) + .orderBy( + desc(schema.eventInstances.startDate), + desc(schema.eventInstances.startTime), + ); + + return { eventInstances: instances }; + }), + + /** + * Get past events without any Q or Co-Q assigned + * Used for backblast selection menu "unclaimed events" section + */ + getEventsWithoutQ: protectedProcedure + .input( + z.object({ + regionOrgId: z.coerce.number(), + /** Only return events without a posted backblast (backblast_ts IS NULL) */ + notPostedOnly: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .default("true"), + /** Maximum number of events to return */ + limit: z.coerce.number().optional().default(20), + }), + ) + .route({ + method: "GET", + path: "/without-q", + tags: ["event-instance"], + summary: "Get past events without Q assigned", + description: "Get past events that have no Q or Co-Q attendance assigned", + }) + .handler(async ({ context: ctx, input }) => { + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + + // Get today's date in YYYY-MM-DD format + const today = new Date().toISOString().split("T")[0]!; + + // Subquery to find event instances that DO have Q or Co-Q attendance + const eventsWithQ = ctx.db + .selectDistinct({ eventInstanceId: schema.attendance.eventInstanceId }) + .from(schema.attendance) + .innerJoin( + schema.attendanceXAttendanceTypes, + eq( + schema.attendanceXAttendanceTypes.attendanceId, + schema.attendance.id, + ), + ) + .where( + and( + eq(schema.attendance.isPlanned, true), + inArray(schema.attendanceXAttendanceTypes.attendanceTypeId, [2, 3]), + ), + ); + + // Build where conditions + const whereConditions = [ + // Event is active + eq(schema.eventInstances.isActive, true), + // Start date is today or earlier (past events) + sql`${schema.eventInstances.startDate} <= ${today}`, + // Event is in the region or a child org of the region + or( + eq(schema.eventInstances.orgId, input.regionOrgId), + eq(aoOrg.parentId, input.regionOrgId), + ), + // Exclude events that have Q or Co-Q + sql`${schema.eventInstances.id} NOT IN (${eventsWithQ})`, + ]; + + // Add backblast_ts IS NULL filter if notPostedOnly is true + if (input.notPostedOnly) { + whereConditions.push(isNull(schema.eventInstances.backblastTs)); + } + + const instances = await ctx.db + .selectDistinct({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + orgId: schema.eventInstances.orgId, + orgName: aoOrg.name, + locationId: schema.eventInstances.locationId, + seriesId: schema.eventInstances.seriesId, + backblastTs: schema.eventInstances.backblastTs, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .where(and(...whereConditions)) + .orderBy( + desc(schema.eventInstances.startDate), + desc(schema.eventInstances.startTime), + ) + .limit(input.limit); + + return { eventInstances: instances }; + }), + + /** + * Get calendar home schedule view + * Optimized query using CTE pattern to limit attendance table scans. + * Returns events with aggregated Q names and user attendance status. + */ + calendarHomeSchedule: protectedProcedure + .input( + z.object({ + userId: z.coerce.number(), + regionOrgId: z.coerce.number(), + aoOrgIds: arrayOrSingle(z.coerce.number()).optional(), + startDate: z.string().optional(), + eventTypeIds: arrayOrSingle(z.coerce.number()).optional(), + // Use transform instead of coerce for booleans to handle "false" string correctly + openQOnly: z + .union([z.boolean(), z.string()]) + .transform((val) => val === true || val === "true") + .optional() + .default(false), + onlyUserEvents: z + .union([z.boolean(), z.string()]) + .transform((val) => val === true || val === "true") + .optional() + .default(false), + limit: z.coerce.number().optional().default(45), + }), + ) + .route({ + method: "GET", + path: "/calendar-home-schedule", + tags: ["event-instance"], + summary: "Get calendar home schedule", + description: + "Get events for calendar home view with Q info and user attendance status", + }) + .handler(async ({ context: ctx, input }) => { + const aoOrg = aliasedTable(schema.orgs, "ao_org"); + const seriesEvent = aliasedTable(schema.events, "series_event"); + + // Get today's date if no start date provided + const startDate = + input.startDate ?? new Date().toISOString().split("T")[0]!; + + // Build org filter based on whether specific AOs are selected + // If specific AO IDs provided, filter by those exact orgs + // Otherwise, filter by events where the org's parent is the region (i.e., all AOs under region) + const orgFilter = input.aoOrgIds?.length + ? inArray(schema.eventInstances.orgId, input.aoOrgIds) + : eq(aoOrg.parentId, input.regionOrgId); + + // Build base filters for event instances + const baseFilters = [ + eq(schema.eventInstances.isActive, true), + gte(schema.eventInstances.startDate, startDate), + orgFilter, + ]; + + // Add event type filter if provided + if (input.eventTypeIds?.length) { + baseFilters.push( + inArray( + schema.eventInstancesXEventTypes.eventTypeId, + input.eventTypeIds, + ), + ); + } + + // Use optimized CTE pattern when not filtering by attendance data + const useOptimization = !input.openQOnly && !input.onlyUserEvents; + + if (useOptimization) { + // OPTIMIZED PATH: Use CTE to limit first, then join attendance + // Step 1: Get candidate event IDs (include ORDER BY columns in select for DISTINCT) + const candidateQuery = ctx.db + .selectDistinct({ + eventId: schema.eventInstances.id, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .leftJoin( + schema.eventInstancesXEventTypes, + eq( + schema.eventInstancesXEventTypes.eventInstanceId, + schema.eventInstances.id, + ), + ) + .leftJoin( + schema.eventTypes, + eq( + schema.eventTypes.id, + schema.eventInstancesXEventTypes.eventTypeId, + ), + ) + .where(and(...baseFilters)) + .orderBy( + asc(schema.eventInstances.startDate), + asc(schema.eventInstances.startTime), + asc(schema.eventInstances.id), + ) + .limit(input.limit); + + // Execute candidate query first + const candidateIds = await candidateQuery; + const eventIds = candidateIds.map((c) => c.eventId); + + if (eventIds.length === 0) { + return { events: [] }; + } + + // Step 2: Main query with attendance aggregation for only these IDs + const events = await ctx.db + .select({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + orgId: schema.eventInstances.orgId, + orgName: aoOrg.name, + seriesId: schema.eventInstances.seriesId, + seriesName: seriesEvent.name, + hasPreblast: sql`${schema.eventInstances.preblastRich} IS NOT NULL`, + eventTypes: sql<{ id: number; name: string }[]>`COALESCE( + json_agg( + DISTINCT jsonb_build_object( + 'id', ${schema.eventTypes.id}, + 'name', ${schema.eventTypes.name} + ) + ) + FILTER ( + WHERE ${schema.eventTypes.id} IS NOT NULL + ), + '[]' + )`, + plannedQs: sql`( + SELECT string_agg(u.f3_name, ', ') + FROM attendance a + JOIN users u ON u.id = a.user_id + JOIN attendance_x_attendance_types axat ON axat.attendance_id = a.id + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.is_planned = true + AND axat.attendance_type_id IN (2, 3) + )`, + userAttending: sql`EXISTS( + SELECT 1 FROM attendance a + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.user_id = ${input.userId} + AND a.is_planned = true + )`, + userIsQ: sql`EXISTS( + SELECT 1 FROM attendance a + JOIN attendance_x_attendance_types axat ON axat.attendance_id = a.id + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.user_id = ${input.userId} + AND a.is_planned = true + AND axat.attendance_type_id IN (2, 3) + )`, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .leftJoin( + seriesEvent, + eq(seriesEvent.id, schema.eventInstances.seriesId), + ) + .leftJoin( + schema.eventInstancesXEventTypes, + eq( + schema.eventInstancesXEventTypes.eventInstanceId, + schema.eventInstances.id, + ), + ) + .leftJoin( + schema.eventTypes, + eq( + schema.eventTypes.id, + schema.eventInstancesXEventTypes.eventTypeId, + ), + ) + .where(inArray(schema.eventInstances.id, eventIds)) + .groupBy( + schema.eventInstances.id, + schema.eventInstances.name, + schema.eventInstances.startDate, + schema.eventInstances.startTime, + schema.eventInstances.orgId, + schema.eventInstances.seriesId, + aoOrg.name, + seriesEvent.name, + ) + .orderBy( + asc(schema.eventInstances.startDate), + asc(schema.eventInstances.startTime), + asc(schema.eventInstances.id), + ); + + return { events }; + } else { + // NON-OPTIMIZED PATH: For openQOnly or onlyUserEvents filters + // Must calculate attendance before applying limit + const events = await ctx.db + .select({ + id: schema.eventInstances.id, + name: schema.eventInstances.name, + startDate: schema.eventInstances.startDate, + startTime: schema.eventInstances.startTime, + orgId: schema.eventInstances.orgId, + orgName: aoOrg.name, + seriesId: schema.eventInstances.seriesId, + seriesName: seriesEvent.name, + hasPreblast: sql`${schema.eventInstances.preblastRich} IS NOT NULL`, + eventTypes: sql<{ id: number; name: string }[]>`COALESCE( + json_agg( + DISTINCT jsonb_build_object( + 'id', ${schema.eventTypes.id}, + 'name', ${schema.eventTypes.name} + ) + ) + FILTER ( + WHERE ${schema.eventTypes.id} IS NOT NULL + ), + '[]' + )`, + plannedQs: sql`( + SELECT string_agg(u.f3_name, ', ') + FROM attendance a + JOIN users u ON u.id = a.user_id + JOIN attendance_x_attendance_types axat ON axat.attendance_id = a.id + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.is_planned = true + AND axat.attendance_type_id IN (2, 3) + )`, + userAttending: sql`EXISTS( + SELECT 1 FROM attendance a + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.user_id = ${input.userId} + AND a.is_planned = true + )`, + userIsQ: sql`EXISTS( + SELECT 1 FROM attendance a + JOIN attendance_x_attendance_types axat ON axat.attendance_id = a.id + WHERE a.event_instance_id = ${schema.eventInstances.id} + AND a.user_id = ${input.userId} + AND a.is_planned = true + AND axat.attendance_type_id IN (2, 3) + )`, + }) + .from(schema.eventInstances) + .leftJoin(aoOrg, eq(aoOrg.id, schema.eventInstances.orgId)) + .leftJoin( + seriesEvent, + eq(seriesEvent.id, schema.eventInstances.seriesId), + ) + .leftJoin( + schema.eventInstancesXEventTypes, + eq( + schema.eventInstancesXEventTypes.eventInstanceId, + schema.eventInstances.id, + ), + ) + .leftJoin( + schema.eventTypes, + eq( + schema.eventTypes.id, + schema.eventInstancesXEventTypes.eventTypeId, + ), + ) + .where(and(...baseFilters)) + .groupBy( + schema.eventInstances.id, + schema.eventInstances.name, + schema.eventInstances.startDate, + schema.eventInstances.startTime, + schema.eventInstances.orgId, + schema.eventInstances.seriesId, + aoOrg.name, + seriesEvent.name, + ) + .orderBy( + asc(schema.eventInstances.startDate), + asc(schema.eventInstances.startTime), + asc(schema.eventInstances.id), + ) + .limit(input.limit * 5); + + // Apply post-query filters + let filteredEvents = events; + + if (input.openQOnly) { + filteredEvents = filteredEvents.filter((e) => !e.plannedQs); + } + + if (input.onlyUserEvents) { + filteredEvents = filteredEvents.filter((e) => e.userAttending); + } + + // Apply limit after filtering + return { events: filteredEvents.slice(0, input.limit) }; + } + }), +}; diff --git a/packages/api/src/router/event-tag.test.ts b/packages/api/src/router/event-tag.test.ts new file mode 100644 index 00000000..0faf6888 --- /dev/null +++ b/packages/api/src/router/event-tag.test.ts @@ -0,0 +1,532 @@ +/** + * Tests for Event Tag Router endpoints + * + * These tests require: + * - TEST_DATABASE_URL environment variable to be set + * - Test database to be seeded with test data + */ + +import { vi } from "vitest"; + +// Use vi.hoisted to ensure mockLimit is available when vi.mock runs (mocks are hoisted) +const mockLimit = vi.hoisted(() => vi.fn()); + +vi.mock("@orpc/experimental-ratelimit/memory", () => ({ + MemoryRatelimiter: vi.fn().mockImplementation(() => ({ + limit: mockLimit, + })), +})); + +import { eq, schema } from "@acme/db"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { + cleanup, + createAdminSession, + createTestClient, + db, + getOrCreateF3NationOrg, + mockAuthWithSession, + uniqueId, +} from "../__tests__/test-utils"; + +describe("Event Tag Router", () => { + // Track created resources for cleanup + const createdEventTagIds: number[] = []; + const createdOrgIds: number[] = []; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset rate limiter to allow requests + mockLimit.mockResolvedValue({ + success: true, + limit: 10, + remaining: 9, + reset: Date.now() + 60000, + }); + }); + + afterAll(async () => { + // Clean up in reverse order + for (const eventTagId of createdEventTagIds.reverse()) { + try { + // First delete any event tag links + await db + .delete(schema.eventTagsXEventInstances) + .where(eq(schema.eventTagsXEventInstances.eventTagId, eventTagId)); + await db + .delete(schema.eventTagsXEvents) + .where(eq(schema.eventTagsXEvents.eventTagId, eventTagId)); + // Then delete the event tag + await db + .delete(schema.eventTags) + .where(eq(schema.eventTags.id, eventTagId)); + } catch { + // Ignore errors during cleanup + } + } + for (const orgId of createdOrgIds.reverse()) { + try { + await cleanup.org(orgId); + } catch { + // Ignore errors during cleanup + } + } + }); + + // Helper to create a test region + const createTestRegion = async () => { + const nationOrg = await getOrCreateF3NationOrg(); + const [region] = await db + .insert(schema.orgs) + .values({ + name: `Test Region ${uniqueId()}`, + orgType: "region", + parentId: nationOrg.id, + isActive: true, + }) + .returning(); + + if (region) { + createdOrgIds.push(region.id); + } + return region; + }; + + // Helper to create a test event tag + const createTestEventTag = async (options?: { + name?: string; + specificOrgId?: number | null; + isActive?: boolean; + }) => { + const [eventTag] = await db + .insert(schema.eventTags) + .values({ + name: options?.name ?? `Test Event Tag ${uniqueId()}`, + specificOrgId: options?.specificOrgId ?? null, + isActive: options?.isActive ?? true, + color: "#FF0000", + }) + .returning(); + + if (eventTag) { + createdEventTagIds.push(eventTag.id); + } + return eventTag; + }; + + describe("all", () => { + it("should return a list of event tags", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const result = await client.eventTag.all({ + pageIndex: 0, + pageSize: 10, + }); + + expect(result).toHaveProperty("eventTags"); + expect(result).toHaveProperty("totalCount"); + expect(Array.isArray(result.eventTags)).toBe(true); + }); + + it("should paginate results correctly", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const page1 = await client.eventTag.all({ + pageIndex: 0, + pageSize: 2, + }); + + const page2 = await client.eventTag.all({ + pageIndex: 1, + pageSize: 2, + }); + + expect(page1.eventTags.length).toBeLessThanOrEqual(2); + expect(page2.eventTags.length).toBeLessThanOrEqual(2); + + // Results should be different if there are more than 2 event tags + if ( + page1.totalCount > 2 && + page1.eventTags.length > 0 && + page2.eventTags.length > 0 + ) { + expect(page1.eventTags[0]?.id).not.toBe(page2.eventTags[0]?.id); + } + }); + + it("should search by name", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const uniqueName = `SearchableEventTag ${uniqueId()}`; + const eventTag = await createTestEventTag({ name: uniqueName }); + + const client = createTestClient(); + const result = await client.eventTag.all({ + searchTerm: "SearchableEventTag", + pageIndex: 0, + pageSize: 10, + }); + + // Results should include our created event tag + const found = result.eventTags.some((et) => et.id === eventTag?.id); + expect(found).toBe(true); + }); + + it("should filter by org", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + // Create an event tag for this region + const eventTag = await createTestEventTag({ + specificOrgId: region.id, + }); + + const client = createTestClient(); + const result = await client.eventTag.all({ + orgIds: [region.id], + pageIndex: 0, + pageSize: 10, + }); + + // Should include our created event tag + const found = result.eventTags.some((et) => et.id === eventTag?.id); + expect(found).toBe(true); + }); + + it("should include nation-wide event tags by default", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + // Create a nation-wide event tag (no specificOrgId) + const nationEventTag = await createTestEventTag({ + name: `Nation Event Tag ${uniqueId()}`, + specificOrgId: null, + }); + + const client = createTestClient(); + const result = await client.eventTag.all({ + orgIds: [region.id], + pageIndex: 0, + pageSize: 100, + }); + + // Should include nation-wide event tag + const found = result.eventTags.some((et) => et.id === nationEventTag?.id); + expect(found).toBe(true); + }); + + it("should exclude nation-wide event tags when ignoreNationEventTags is true", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + // Create a region-specific event tag + const regionEventTag = await createTestEventTag({ + name: `Region Event Tag ${uniqueId()}`, + specificOrgId: region.id, + }); + + // Create a nation-wide event tag + const nationEventTag = await createTestEventTag({ + name: `Nation Event Tag ${uniqueId()}`, + specificOrgId: null, + }); + + const client = createTestClient(); + const result = await client.eventTag.all({ + orgIds: [region.id], + ignoreNationEventTags: true, + pageIndex: 0, + pageSize: 100, + }); + + // Should include region-specific event tag + const foundRegionTag = result.eventTags.some( + (et) => et.id === regionEventTag?.id, + ); + expect(foundRegionTag).toBe(true); + + // Should NOT include nation-wide event tag + const foundNationTag = result.eventTags.some( + (et) => et.id === nationEventTag?.id, + ); + expect(foundNationTag).toBe(false); + }); + + it("should filter by status", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const activeResult = await client.eventTag.all({ + statuses: ["active"], + pageIndex: 0, + pageSize: 10, + }); + + expect(activeResult.eventTags.every((et) => et.isActive === true)).toBe( + true, + ); + }); + }); + + describe("byId", () => { + it("should return event tag by ID", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const eventTag = await createTestEventTag(); + if (!eventTag) return; + + const client = createTestClient(); + const result = await client.eventTag.byId({ + id: eventTag.id, + }); + + expect(result).toHaveProperty("eventTag"); + expect(result.eventTag).not.toBeNull(); + expect(result.eventTag?.id).toBe(eventTag.id); + }); + + it("should throw NOT_FOUND for non-existent event tag", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + + await expect( + client.eventTag.byId({ + id: 999999, + }), + ).rejects.toThrow(); + }); + }); + + describe("byOrgId", () => { + it("should return event tags for a specific org", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + // Create an event tag for this org + const eventTag = await createTestEventTag({ + specificOrgId: region.id, + }); + + const client = createTestClient(); + const result = await client.eventTag.byOrgId({ + orgId: region.id, + }); + + expect(result).toHaveProperty("eventTags"); + expect(Array.isArray(result.eventTags)).toBe(true); + + // Should include our created event tag + const found = result.eventTags.some((et) => et.id === eventTag?.id); + expect(found).toBe(true); + }); + + it("should return empty for org with no event tags", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const client = createTestClient(); + const result = await client.eventTag.byOrgId({ + orgId: region.id, + }); + + // Should return empty array (no event tags for this specific org) + expect(result.eventTags).toEqual([]); + }); + + it("should filter by active status", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + // Create active and inactive event tags + await createTestEventTag({ + specificOrgId: region.id, + isActive: true, + }); + + await createTestEventTag({ + specificOrgId: region.id, + isActive: false, + }); + + const client = createTestClient(); + const result = await client.eventTag.byOrgId({ + orgId: region.id, + isActive: true, + }); + + // All returned should be active + expect(result.eventTags.every((et) => et.isActive === true)).toBe(true); + }); + }); + + describe("crupdate", () => { + it("should create a new event tag for nation", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + const eventTagName = `Test Event Tag ${uniqueId()}`; + + const result = await client.eventTag.crupdate({ + name: eventTagName, + color: "#00FF00", + isActive: true, + }); + + expect(result).toHaveProperty("eventTag"); + expect(Array.isArray(result.eventTag)).toBe(true); + expect(result.eventTag.length).toBeGreaterThan(0); + + const created = result.eventTag[0]; + if (created) { + expect(created.name).toBe(eventTagName); + createdEventTagIds.push(created.id); + } + }); + + it("should create event tag for a specific org", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const region = await createTestRegion(); + if (!region) return; + + const client = createTestClient(); + const eventTagName = `Org Specific Event Tag ${uniqueId()}`; + + const result = await client.eventTag.crupdate({ + name: eventTagName, + color: "#0000FF", + specificOrgId: region.id, + isActive: true, + }); + + expect(result).toHaveProperty("eventTag"); + expect(Array.isArray(result.eventTag)).toBe(true); + + const created = result.eventTag[0]; + if (created) { + expect(created.name).toBe(eventTagName); + expect(created.specificOrgId).toBe(region.id); + createdEventTagIds.push(created.id); + } + }); + + it("should update an existing event tag", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const eventTag = await createTestEventTag(); + if (!eventTag) return; + + const client = createTestClient(); + const updatedName = `Updated Event Tag ${uniqueId()}`; + + const result = await client.eventTag.crupdate({ + id: eventTag.id, + name: updatedName, + color: "#FFFF00", + isActive: true, + }); + + expect(result).toHaveProperty("eventTag"); + expect(Array.isArray(result.eventTag)).toBe(true); + + const updated = result.eventTag[0]; + expect(updated?.id).toBe(eventTag.id); + expect(updated?.name).toBe(updatedName); + }); + }); + + describe("delete", () => { + it("should soft delete event tag for nation admin", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const eventTag = await createTestEventTag(); + if (!eventTag) return; + + const client = createTestClient(); + await client.eventTag.delete({ + id: eventTag.id, + }); + + // Verify soft deletion (isActive should be false) + const [deleted] = await db + .select() + .from(schema.eventTags) + .where(eq(schema.eventTags.id, eventTag.id)); + + expect(deleted).toBeDefined(); + expect(deleted?.isActive).toBe(false); + }); + + it("should silently succeed for non-existent event tag", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + const client = createTestClient(); + + // The delete endpoint doesn't throw for non-existent records + // It just silently updates nothing + await expect( + client.eventTag.delete({ + id: 999999, + }), + ).resolves.not.toThrow(); + }); + }); + + describe("sorting", () => { + it("should sort by name", async () => { + const session = await createAdminSession(); + await mockAuthWithSession(session); + + // Create event tags with different names + await createTestEventTag({ name: `AAA Tag ${uniqueId()}` }); + await createTestEventTag({ name: `ZZZ Tag ${uniqueId()}` }); + + const client = createTestClient(); + const result = await client.eventTag.all({ + sorting: [{ id: "name", desc: false }], + pageIndex: 0, + pageSize: 100, + }); + + if (result.eventTags.length >= 2) { + const names = result.eventTags.map((et) => et.name); + // Should be sorted ascending + for (let i = 1; i < names.length; i++) { + expect(names[i]!.localeCompare(names[i - 1]!) >= 0).toBe(true); + } + } + }); + }); +}); diff --git a/packages/api/src/router/event-tag.ts b/packages/api/src/router/event-tag.ts new file mode 100644 index 00000000..f8947685 --- /dev/null +++ b/packages/api/src/router/event-tag.ts @@ -0,0 +1,308 @@ +import { ORPCError } from "@orpc/server"; +import { z } from "zod"; + +import type { InferInsertModel } from "@acme/db"; +import { + and, + asc, + count, + desc, + eq, + ilike, + inArray, + isNull, + or, + schema, +} from "@acme/db"; +import { IsActiveStatus } from "@acme/shared/app/enums"; +import { arrayOrSingle, parseSorting } from "@acme/shared/app/functions"; +import { EventTagInsertSchema } from "@acme/validators"; + +import { checkHasRoleOnOrg } from "../check-has-role-on-org"; +import { editorProcedure, protectedProcedure } from "../shared"; +import { withPagination } from "../with-pagination"; + +export const eventTagRouter = { + /** + * By default this gets all the event tags available for the orgIds (meaning that general, nation-wide event tags are included) + * To get only the event tags for a specific org, set ignoreNationEventTags to true + */ + all: protectedProcedure + .input( + z + .object({ + orgIds: arrayOrSingle(z.coerce.number()).optional(), + statuses: arrayOrSingle(z.enum(IsActiveStatus)).optional(), + pageIndex: z.coerce.number().optional(), + pageSize: z.coerce.number().optional(), + searchTerm: z.string().optional(), + sorting: parseSorting(), + ignoreNationEventTags: z.coerce.boolean().optional(), + }) + .optional(), + ) + .route({ + method: "GET", + path: "/", + tags: ["event-tag"], + summary: "List all event tags", + description: + "Get a paginated list of event tags with optional filtering by organization", + }) + .handler(async ({ context: ctx, input }) => { + const limit = input?.pageSize ?? 10; + const offset = (input?.pageIndex ?? 0) * limit; + const usePagination = + input?.pageIndex !== undefined && input?.pageSize !== undefined; + + const sortedColumns = input?.sorting?.map((sorting) => { + const direction = sorting.desc ? desc : asc; + switch (sorting.id) { + case "name": + return direction(schema.eventTags.name); + case "description": + return direction(schema.eventTags.description); + case "color": + return direction(schema.eventTags.color); + case "specificOrgName": + return direction(schema.orgs.name); + case "created": + return direction(schema.eventTags.created); + default: + return direction(schema.eventTags.id); + } + }) ?? [desc(schema.eventTags.id)]; + + const select = { + id: schema.eventTags.id, + name: schema.eventTags.name, + description: schema.eventTags.description, + color: schema.eventTags.color, + specificOrgId: schema.eventTags.specificOrgId, + specificOrgName: schema.orgs.name, + isActive: schema.eventTags.isActive, + }; + + const where = and( + input?.searchTerm + ? or( + ilike(schema.eventTags.name, `%${input?.searchTerm}%`), + ilike(schema.eventTags.description, `%${input?.searchTerm}%`), + ) + : undefined, + input?.orgIds?.length + ? or( + inArray(schema.eventTags.specificOrgId, input?.orgIds), + input?.ignoreNationEventTags + ? undefined + : isNull(schema.eventTags.specificOrgId), + ) + : undefined, + !input?.statuses?.length || + input.statuses.length === IsActiveStatus.length + ? undefined + : input.statuses.includes("active") + ? eq(schema.eventTags.isActive, true) + : eq(schema.eventTags.isActive, false), + ); + + const [eventTagCount] = await ctx.db + .select({ count: count(schema.eventTags.id) }) + .from(schema.eventTags) + .where(where); + + const totalCount = eventTagCount?.count ?? 0; + + const query = ctx.db + .select(select) + .from(schema.eventTags) + .leftJoin( + schema.orgs, + eq(schema.eventTags.specificOrgId, schema.orgs.id), + ) + .where(where); + + const eventTags = usePagination + ? await withPagination(query.$dynamic(), sortedColumns, offset, limit) + : await query.orderBy(...sortedColumns); + + return { eventTags, totalCount }; + }), + byOrgId: protectedProcedure + .input( + z.object({ + orgId: z.coerce.number(), + isActive: z.coerce.boolean().optional(), + }), + ) + .route({ + method: "GET", + path: "/org/{orgId}", + tags: ["event-tag"], + summary: "Get event tags by organization", + description: "Retrieve all event tags for a specific organization", + }) + .handler(async ({ context: ctx, input }) => { + const eventTags = await ctx.db + .select() + .from(schema.eventTags) + .where( + and( + eq(schema.eventTags.specificOrgId, input.orgId), + input.isActive !== undefined + ? eq(schema.eventTags.isActive, input.isActive) + : eq(schema.eventTags.isActive, true), + ), + ); + + return { eventTags: eventTags ?? null }; + }), + byId: protectedProcedure + .input(z.object({ id: z.coerce.number() })) + .route({ + method: "GET", + path: "/id/{id}", + tags: ["event-tag"], + summary: "Get event tag by ID", + description: "Retrieve detailed information about a specific event tag", + }) + .handler(async ({ context: ctx, input }) => { + const [result] = await ctx.db + .select() + .from(schema.eventTags) + .where(eq(schema.eventTags.id, input.id)); + + if (!result) { + throw new ORPCError("NOT_FOUND", { + message: "Event tag not found", + }); + } + + return { eventTag: result ?? null }; + }), + crupdate: editorProcedure + .input(EventTagInsertSchema) + .route({ + method: "POST", + path: "/", + tags: ["event-tag"], + summary: "Create or update event tag", + description: "Create a new event tag or update an existing one", + }) + .handler(async ({ context: ctx, input }) => { + const [existingEventTag] = input.id + ? await ctx.db + .select() + .from(schema.eventTags) + .where(eq(schema.eventTags.id, input.id)) + : []; + + const [nationOrg] = await ctx.db + .select({ id: schema.orgs.id }) + .from(schema.orgs) + .where(eq(schema.orgs.orgType, "nation")); + + if (!nationOrg) { + throw new ORPCError("NOT_FOUND", { + message: "Nation organization not found", + }); + } + + const orgIdForPermissionCheck = existingEventTag?.specificOrgId + ? existingEventTag.specificOrgId + : existingEventTag + ? nationOrg.id + : input.specificOrgId ?? nationOrg.id; + + const roleCheckResult = await checkHasRoleOnOrg({ + orgId: orgIdForPermissionCheck, + session: ctx.session, + db: ctx.db, + roleName: "editor", + }); + + if (!roleCheckResult.success) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to update this Event Tag", + }); + } + + // If changing the specificOrgId, verify permission on the destination org + if ( + input.specificOrgId !== undefined && + input.specificOrgId !== existingEventTag?.specificOrgId + ) { + const destinationOrgId = input.specificOrgId ?? nationOrg.id; + const destinationRoleCheck = await checkHasRoleOnOrg({ + orgId: destinationOrgId, + session: ctx.session, + db: ctx.db, + roleName: "editor", + }); + + if (!destinationRoleCheck.success) { + throw new ORPCError("UNAUTHORIZED", { + message: + "You are not authorized to create tags for the target organization", + }); + } + } + + const eventTagData: InferInsertModel = { + ...input, + }; + const result = await ctx.db + .insert(schema.eventTags) + .values(eventTagData) + .onConflictDoUpdate({ + target: schema.eventTags.id, + set: eventTagData, + }) + .returning(); + + return { eventTag: result ?? null }; + }), + delete: editorProcedure + .input(z.object({ id: z.coerce.number() })) + .route({ + method: "DELETE", + path: "/id/{id}", + tags: ["event-tag"], + summary: "Delete event tag", + description: "Soft delete an event tag by marking it as inactive", + }) + .handler(async ({ context: ctx, input }) => { + const [existingEventTag] = await ctx.db + .select() + .from(schema.eventTags) + .where(eq(schema.eventTags.id, input.id)); + + const [nationOrg] = await ctx.db + .select({ id: schema.orgs.id }) + .from(schema.orgs) + .where(eq(schema.orgs.orgType, "nation")); + + if (!nationOrg) { + throw new ORPCError("NOT_FOUND", { + message: "Nation organization not found", + }); + } + + const roleCheckResult = await checkHasRoleOnOrg({ + orgId: existingEventTag?.specificOrgId ?? nationOrg.id, + session: ctx.session, + db: ctx.db, + roleName: "editor", + }); + if (!roleCheckResult.success) { + throw new ORPCError("UNAUTHORIZED", { + message: "You are not authorized to delete this Event Tag", + }); + } + + await ctx.db + .update(schema.eventTags) + .set({ isActive: false }) + .where(eq(schema.eventTags.id, input.id)); + }), +}; diff --git a/packages/api/src/router/org.test.ts b/packages/api/src/router/org.test.ts index 1fcd2f71..fea911ae 100644 --- a/packages/api/src/router/org.test.ts +++ b/packages/api/src/router/org.test.ts @@ -209,6 +209,11 @@ describe("Org Router", () => { parentId: f3Nation.id, isActive: true, email: "test@example.com", + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }); expect(result).toHaveProperty("org"); @@ -233,6 +238,11 @@ describe("Org Router", () => { orgType: "region", isActive: true, email: "test@example.com", + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }), ).rejects.toThrow("Parent ID or ID is required"); }); @@ -269,6 +279,11 @@ describe("Org Router", () => { parentId: f3Nation.id, isActive: true, email: "test@example.com", + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }); expect(result.org?.id).toBe(testOrg.id); @@ -294,6 +309,11 @@ describe("Org Router", () => { parentId: f3Nation.id, isActive: true, email: "test@example.com", + description: null, + website: null, + twitter: null, + facebook: null, + instagram: null, }), ).rejects.toThrow(); }); diff --git a/packages/validators/src/index.ts b/packages/validators/src/index.ts index f37a6467..27f9b5d2 100644 --- a/packages/validators/src/index.ts +++ b/packages/validators/src/index.ts @@ -3,9 +3,12 @@ import { z } from "zod"; import { events, + eventTags, eventTypes, locations, orgs, + slackSpaces, + slackUsers, updateRequests, users, } from "@acme/db/schema/schema"; @@ -52,6 +55,10 @@ export const EventTypeInsertSchema = createInsertSchema(eventTypes, { }); export const EventTypeSelectSchema = createSelectSchema(eventTypes); +// EVENT TAG SCHEMA +export const EventTagInsertSchema = createInsertSchema(eventTags); +export const EventTagSelectSchema = createSelectSchema(eventTags); + // EVENT SCHEMA export const EventInsertSchema = createInsertSchema(events, { name: (s: z.ZodString) => s.min(1, { message: "Name is required" }), @@ -77,6 +84,7 @@ export const EventInsertSchema = createInsertSchema(events, { .number() .array() .min(1, { message: "Event type is required" }), + eventTagIds: z.number().array().optional(), }) .omit({ orgId: true, @@ -94,7 +102,12 @@ export type EventInsertType = z.infer; export const NationInsertSchema = createInsertSchema(orgs, { name: (s: z.ZodString) => s.min(1, { message: "Name is required" }), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + description: (s: z.ZodString) => s.nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), parentId: z.null({ message: "Must not have a parent" }).optional(), }).omit({ orgType: true }); export const NationSelectSchema = createSelectSchema(orgs); @@ -106,7 +119,12 @@ export const SectorInsertSchema = createInsertSchema(orgs, { .number({ message: "Must have a parent" }) .nonnegative({ message: "Invalid selection" }), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + description: (s: z.ZodString) => s.nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), }).omit({ orgType: true }); export const SectorSelectSchema = createSelectSchema(orgs); @@ -117,7 +135,12 @@ export const AreaInsertSchema = createInsertSchema(orgs, { .number({ message: "Must have a parent" }) .nonnegative({ message: "Invalid selection" }), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + description: (s: z.ZodString) => s.nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), }).omit({ orgType: true }); export const AreaSelectSchema = createSelectSchema(orgs); @@ -128,7 +151,12 @@ export const RegionInsertSchema = createInsertSchema(orgs, { .number({ message: "Must have a parent" }) .nonnegative({ message: "Invalid selection" }), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + description: (s: z.ZodString) => s.nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), }).omit({ orgType: true }); export const RegionSelectSchema = createSelectSchema(orgs); @@ -139,7 +167,12 @@ export const AOInsertSchema = createInsertSchema(orgs, { .number({ message: "Must have a parent" }) .nonnegative({ message: "Invalid selection" }), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + description: (s: z.ZodString) => s.nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), }).omit({ orgType: true }); export const AOSelectSchema = createSelectSchema(orgs); @@ -148,9 +181,15 @@ export const OrgInsertSchema = createInsertSchema(orgs, { name: (s: z.ZodString) => s.min(1, { message: "Name is required" }), parentId: z .number({ message: "Must have a parent" }) - .nonnegative({ message: "Invalid selection" }), + .nonnegative({ message: "Invalid selection" }) + .nullable(), + description: (s: z.ZodString) => s.nullable(), email: (s: z.ZodString) => - s.email({ message: "Invalid email format" }).or(z.literal("")), + s.email({ message: "Invalid email format" }).or(z.literal("")).nullable(), + website: (s: z.ZodString) => s.nullable(), + twitter: (s: z.ZodString) => s.nullable(), + facebook: (s: z.ZodString) => s.nullable(), + instagram: (s: z.ZodString) => s.nullable(), }); export const OrgSelectSchema = createSelectSchema(orgs); @@ -258,6 +297,51 @@ export const DeleteRequestResponseSchema = z.object({ export type DeleteRequestResponse = z.infer; +// SLACK SCHEMA +export const CustomFieldSchema = z.object({ + name: z.string(), + type: z.enum(["text", "select", "multi_select", "user_select"]), + options: z.array(z.string()).optional(), + enabled: z.boolean(), +}); + +export const SlackSettingsSchema = z.object({ + welcome_dm_enable: z.boolean().optional(), + welcome_dm_template: z.string().optional(), + welcome_channel_enable: z.boolean().optional(), + welcome_channel: z.string().optional(), + editing_locked: z.boolean().optional(), + default_backblast_destination: z.string().optional(), + backblast_destination_channel: z.string().optional(), + default_preblast_destination: z.string().optional(), + preblast_destination_channel: z.string().optional(), + backblast_moleskin_template: z.string().optional(), + preblast_moleskin_template: z.string().optional(), + strava_enabled: z.boolean().optional(), + preblast_reminder_days: z.number().optional(), + backblast_reminder_days: z.number().optional(), + automated_preblast_option: z.string().optional(), + automated_preblast_hour_cst: z.number().optional(), + custom_fields: z.array(CustomFieldSchema).optional(), +}); + +export const SlackSpaceSelectSchema = createSelectSchema(slackSpaces); +export const SlackSpaceInsertSchema = createInsertSchema(slackSpaces); + +export const SlackUserSelectSchema = createSelectSchema(slackUsers); +export const SlackUserInsertSchema = createInsertSchema(slackUsers); + +export const SlackUserUpsertSchema = z.object({ + slackId: z.string(), + userName: z.string(), + email: z.string().email().optional(), + teamId: z.string(), + userId: z.number().optional(), + isAdmin: z.boolean().default(false), + isOwner: z.boolean().default(false), + isBot: z.boolean().default(false), +}); + // POSITION SCHEMA import { positions, positionsXOrgsXUsers } from "@acme/db/schema/schema";