From b2db8fc2850eb6bd779d2366ff21bfb1df3338f4 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Wed, 19 Aug 2026 00:27:58 -0700 Subject: [PATCH] feat: cancellation --- README.md | 6 + app/account/account.module.css | 210 +++ app/account/cancel-event-form.tsx | 209 +++ app/account/page.tsx | 116 +- app/admin/events/admin-events.module.css | 19 + app/admin/events/page.tsx | 30 + app/events/types.ts | 1 + db/migrations.test.ts | 63 +- db/schema.ts | 37 + lib/events/actions.ts | 232 ++- lib/events/mapper.test.ts | 20 + lib/events/mapper.ts | 2 + lib/events/queries.ts | 223 ++- lib/events/recurrence.test.ts | 60 + lib/events/recurrence.ts | 40 + lib/events/state.ts | 10 + .../migration.sql | 15 + .../snapshot.json | 1524 +++++++++++++++++ 18 files changed, 2734 insertions(+), 83 deletions(-) create mode 100644 app/account/cancel-event-form.tsx create mode 100644 netlify/database/migrations/20260819071633_young_scourge/migration.sql create mode 100644 netlify/database/migrations/20260819071633_young_scourge/snapshot.json diff --git a/README.md b/README.md index e987251..ad6fb96 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,12 @@ A recurring event has one `event_recurrence` row whose primary key is the parent Event date/time input and recurrence calculations use the fixed IANA timezone `America/Los_Angeles`—Pacific time, switching between PST and PDT automatically. The form intentionally has no timezone picker. `end_date` is a calendar date in that same timezone, while event start and end instants remain timezone-aware timestamps. +### Event cancellations + +Event owners can cancel without another moderation decision. Setting `event.canceled_at` cancels the one-time event or the entire recurring series; `canceled_by` records the owner who performed the cancellation when that account still exists. Cancellation does not rewrite the approval status, so the moderation history remains intact. + +A single occurrence of a recurring event is canceled by inserting its Pacific calendar date into `event_occurrence_cancellation`. The `(event_id, occurrence_date)` pair is unique, preventing duplicate exceptions, and deleting the parent event removes all of its occurrence cancellations. `occurrence_date` is interpreted in `America/Los_Angeles`, matching recurrence generation and avoiding UTC date shifts near midnight. + After changing either schema, generate a SQL migration: ```sh diff --git a/app/account/account.module.css b/app/account/account.module.css index 9764fdb..c880554 100644 --- a/app/account/account.module.css +++ b/app/account/account.module.css @@ -178,6 +178,48 @@ border-color: var(--color-poppy-deep); } +.cardHeading span[data-status="canceled"] { + background: var(--color-navy); + border-color: var(--color-navy); + color: var(--color-paper); +} + +.canceledEventNotice { + background: rgb(35 48 68 / 8%); + border-left: 0.25rem solid var(--color-navy); + color: var(--color-navy); + font-weight: 800; + margin: 1rem 0 0; + padding: 0.75rem 0.9rem; +} + +.canceledOccurrences { + background: rgb(232 86 47 / 7%); + border-left: 0.25rem solid var(--color-poppy-deep); + margin-top: 1rem; + padding: 0.8rem 1rem; +} + +.canceledOccurrences h4 { + font-family: var(--font-display); + font-size: 1.05rem; + margin: 0; +} + +.canceledOccurrences ul { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 1.35rem; + margin: 0.45rem 0 0; + padding-left: 1.2rem; +} + +.canceledOccurrences li { + color: var(--color-ink-muted); + font-size: 0.9rem; + font-weight: 700; +} + .reviewNote { background: var(--color-cream); border-left: 0.25rem solid var(--color-poppy); @@ -189,6 +231,154 @@ margin: 0.25rem 0 0; } +.cancellationPanel { + background: var(--color-cream); + border: 1px solid var(--color-line); + border-left: 0.35rem solid var(--color-poppy-deep); + display: grid; + gap: 1rem; + margin-top: 1.25rem; + padding: clamp(1rem, 3vw, 1.35rem); +} + +.cancellationHeading h4, +.occurrenceCancellation h5, +.eventCancellation h5 { + font-family: var(--font-display); + line-height: 1.2; + margin: 0; +} + +.cancellationHeading h4 { + font-size: 1.25rem; +} + +.occurrenceCancellation h5, +.eventCancellation h5 { + font-size: 1.05rem; +} + +.cancellationHeading p, +.occurrenceCancellation p, +.eventCancellation p { + color: var(--color-ink-muted); + font-size: 0.92rem; + margin: 0.3rem 0 0; +} + +.occurrenceCancellation { + border-top: 1px solid var(--color-line); + display: grid; + gap: 0.85rem; + padding-top: 1rem; +} + +.occurrenceCancellation form { + display: grid; + gap: 0.4rem; +} + +.occurrenceCancellation label { + font-size: 0.84rem; + font-weight: 850; +} + +.occurrenceControls { + align-items: stretch; + display: flex; + flex-wrap: wrap; + gap: 0.65rem; +} + +.occurrenceControls input { + background: #fff; + border: 2px solid #a98d53; + border-radius: var(--radius-small); + color: var(--color-navy-deep); + font: inherit; + min-height: 2.8rem; + padding: 0.55rem 0.65rem; +} + +.occurrenceControls input:hover:not(:disabled), +.occurrenceControls input:focus-visible { + border-color: var(--color-bridge-deep); +} + +.cancelOccurrenceButton, +.cancelEventButton { + border: 2px solid var(--color-poppy-deep); + cursor: pointer; + font-weight: 850; + min-height: 2.8rem; + padding: 0.55rem 0.85rem; +} + +.cancelOccurrenceButton { + background: transparent; + color: var(--color-poppy-deep); +} + +.cancelOccurrenceButton:hover:not(:disabled) { + background: rgb(232 86 47 / 10%); +} + +.eventCancellation { + align-items: center; + border-top: 1px solid var(--color-line); + display: flex; + gap: 1rem; + justify-content: space-between; + padding-top: 1rem; +} + +.eventCancellation form { + flex: 0 0 auto; +} + +.cancelEventButton { + background: var(--color-poppy-deep); + color: var(--color-paper); +} + +.cancelEventButton:hover:not(:disabled) { + background: var(--color-navy); + border-color: var(--color-navy); +} + +.cancelOccurrenceButton:disabled, +.cancelEventButton:disabled, +.occurrenceControls input:disabled { + cursor: wait; + opacity: 0.62; +} + +.noUpcomingOccurrences { + background: rgb(35 48 68 / 7%); + border-left: 0.25rem solid var(--color-navy); + padding: 0.65rem 0.8rem; +} + +.cancelError, +.cancelSuccess { + font-size: 0.9rem; + font-weight: 750; + margin: 0; + padding: 0.65rem 0.8rem; +} + +.cancelError { + background: rgb(232 86 47 / 10%); + border-left: 0.25rem solid var(--color-poppy-deep); + color: var(--color-poppy-deep); +} + +.cancelSuccess { + background: rgb(5 99 106 / 10%); + border-left: 0.25rem solid var(--color-river); + color: var(--color-river-deep); +} + @media (max-width: 44rem) { .hero { align-items: stretch; @@ -203,4 +393,24 @@ align-items: flex-start; flex-direction: column; } + + .eventCancellation { + align-items: stretch; + flex-direction: column; + } + + .eventCancellation form, + .cancelEventButton, + .cancelOccurrenceButton { + width: 100%; + } + + .occurrenceControls { + align-items: stretch; + flex-direction: column; + } + + .occurrenceControls input { + width: 100%; + } } diff --git a/app/account/cancel-event-form.tsx b/app/account/cancel-event-form.tsx new file mode 100644 index 0000000..94b9243 --- /dev/null +++ b/app/account/cancel-event-form.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { cancelEvent } from "@/lib/events/actions"; +import { initialCancellationFormState } from "@/lib/events/state"; +import style from "./account.module.css"; + +interface CancelEventFormProps { + defaultOccurrenceDate: string | null; + eventId: string; + eventTitle: string; + isRecurring: boolean; + maxOccurrenceDate: string | null; + minOccurrenceDate: string; +} + +const occurrenceDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "long", + timeZone: "UTC", +}); + +function formatOccurrenceDate(dateKey: string) { + return occurrenceDateFormatter.format(new Date(`${dateKey}T12:00:00Z`)); +} + +export function CancelEventForm({ + defaultOccurrenceDate, + eventId, + eventTitle, + isRecurring, + maxOccurrenceDate, + minOccurrenceDate, +}: CancelEventFormProps) { + const cancelBoundEvent = cancelEvent.bind(null, eventId); + const [occurrenceState, occurrenceAction, occurrencePending] = + useActionState(cancelBoundEvent, initialCancellationFormState); + const [eventState, eventAction, eventPending] = useActionState( + cancelBoundEvent, + initialCancellationFormState, + ); + const [occurrenceSelection, setOccurrenceSelection] = useState({ + defaultDate: defaultOccurrenceDate, + value: defaultOccurrenceDate ?? "", + }); + const pending = occurrencePending || eventPending; + + if (occurrenceSelection.defaultDate !== defaultOccurrenceDate) { + setOccurrenceSelection({ + defaultDate: defaultOccurrenceDate, + value: defaultOccurrenceDate ?? "", + }); + } + + const occurrenceDate = occurrenceSelection.value; + + function confirmOccurrenceCancellation( + event: React.FormEvent, + ) { + if ( + !window.confirm( + `Cancel the ${formatOccurrenceDate(occurrenceDate)} occurrence of “${eventTitle}”? The rest of the series will remain scheduled.`, + ) + ) { + event.preventDefault(); + } + } + + function confirmEventCancellation(event: React.FormEvent) { + const description = isRecurring + ? "This removes the entire series from the calendar, including every future occurrence." + : "This removes the event from the calendar."; + + if (!window.confirm(`Cancel “${eventTitle}”? ${description}`)) { + event.preventDefault(); + } + } + + return ( +
+
+

{isRecurring ? "Manage this series" : "Cancel this event"}

+

+ Cancellations take effect immediately and do not need another admin + review. +

+
+ + {isRecurring && ( +
+
+
Cancel one occurrence
+

+ Choose a scheduled date. Every other date stays on the calendar. +

+
+ {defaultOccurrenceDate ? ( +
+ + +
+ + setOccurrenceSelection((current) => ({ + ...current, + value: event.target.value, + })) + } + required + type="date" + value={occurrenceDate} + /> + +
+
+ ) : ( +

+ This series has no upcoming occurrences available to cancel. +

+ )} +
+ {occurrenceState.message && ( +

+ {occurrenceState.message} +

+ )} +
+
+ )} + +
+
+
{isRecurring ? "Cancel the entire series" : "Cancel event"}
+

+ {isRecurring + ? "Every occurrence will be removed from the calendar." + : "The event will be removed from the calendar."} +

+
+
+ + +
+
+
+ {eventState.message && ( +

+ {eventState.message} +

+ )} +
+
+ ); +} diff --git a/app/account/page.tsx b/app/account/page.tsx index 5aa708b..66da567 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,7 +1,13 @@ import type { Metadata } from "next"; import Link from "next/link"; +import type { RecurrenceRule } from "@/app/events/types"; import { getSubmissionsForUser } from "@/lib/events/queries"; +import { + getNextFutureOccurrence, + RECURRENCE_TIME_ZONE, +} from "@/lib/events/recurrence"; import { requireSession, sessionIsAdmin } from "@/lib/session"; +import { CancelEventForm } from "./cancel-event-form"; import { SignOutButton } from "./sign-out-button"; import style from "./account.module.css"; @@ -18,10 +24,23 @@ const dateFormatter = new Intl.DateTimeFormat("en-US", { const statusLabels = { approved: "Approved", + canceled: "Canceled", pending: "Pending review", rejected: "Needs changes", } as const; +const cancellationDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "long", + timeZone: "UTC", +}); + +const pacificDatePartsFormatter = new Intl.DateTimeFormat("en-US", { + day: "2-digit", + month: "2-digit", + timeZone: RECURRENCE_TIME_ZONE, + year: "numeric", +}); + const weekdays = [ "Sunday", "Monday", @@ -127,10 +146,52 @@ function formatRecurrenceSummary(rule: RecurrenceSummaryInput) { return summary; } +function formatPacificDateKey(date: Date) { + const parts = pacificDatePartsFormatter.formatToParts(date); + const day = parts.find((part) => part.type === "day")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const year = parts.find((part) => part.type === "year")?.value; + + if (!day || !month || !year) { + throw new Error("Could not determine the current Sacramento date."); + } + + return `${year}-${month}-${day}`; +} + +function formatCancellationDate(dateKey: string) { + return cancellationDateFormatter.format(new Date(`${dateKey}T12:00:00Z`)); +} + +function getRecurrenceRule( + submission: RecurrenceSummaryInput & { canceledOccurrences: string[] }, +): RecurrenceRule | null { + if ( + !submission.recurrenceFrequency || + !submission.recurrenceInterval || + !submission.recurrenceEndType + ) { + return null; + } + + return { + endDate: submission.recurrenceEndDate, + endType: submission.recurrenceEndType, + excludedDates: submission.canceledOccurrences, + frequency: submission.recurrenceFrequency, + interval: submission.recurrenceInterval, + monthlyPattern: submission.recurrenceMonthlyPattern, + occurrenceCount: submission.recurrenceCount, + weekdays: submission.recurrenceWeekdays, + }; +} + export default async function AccountPage() { const session = await requireSession(); const submissions = await getSubmissionsForUser(session.user.id); const isAdmin = sessionIsAdmin(session); + const now = new Date(); + const today = formatPacificDateKey(now); return (
@@ -173,6 +234,24 @@ export default async function AccountPage() {
    {submissions.map((submission) => { const recurrenceSummary = formatRecurrenceSummary(submission); + const recurrenceRule = getRecurrenceRule(submission); + const nextOccurrence = + !submission.canceledAt && recurrenceRule + ? getNextFutureOccurrence( + submission.startsAt, + recurrenceRule, + now, + ) + : null; + const defaultOccurrenceDate = nextOccurrence + ? formatPacificDateKey(nextOccurrence) + : null; + const displayStatus = submission.canceledAt + ? "canceled" + : submission.status; + const canceledOccurrences = [ + ...submission.canceledOccurrences, + ].sort(); return (
  • @@ -186,16 +265,49 @@ export default async function AccountPage() { {recurrenceSummary}

    - - {statusLabels[submission.status]} + + {statusLabels[displayStatus]} + {submission.canceledAt && ( +

    + Canceled on {dateFormatter.format(submission.canceledAt)}. +

    + )} + {canceledOccurrences.length > 0 && ( +
    +

    Canceled occurrences

    +
      + {canceledOccurrences.map((date) => ( +
    • + +
    • + ))} +
    +
    + )} {submission.moderationNote && (
    Admin note

    {submission.moderationNote}

    )} + {!submission.canceledAt && ( + + )}
  • ); })} diff --git a/app/admin/events/admin-events.module.css b/app/admin/events/admin-events.module.css index 5555676..ece5df4 100644 --- a/app/admin/events/admin-events.module.css +++ b/app/admin/events/admin-events.module.css @@ -177,6 +177,25 @@ font-weight: 750; } +.details > div.cancellationDetail { + background: rgb(232 86 47 / 8%); + border-left: 0.3rem solid var(--color-poppy-deep); + grid-column: 1 / -1; + padding: 0.85rem 1rem; +} + +.cancellationDetail dd { + font-weight: 700; +} + +.cancellationDetail ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 1.35rem; + margin: 0; + padding-left: 1.2rem; +} + .details dt { color: var(--color-ink-muted); font-size: 0.78rem; diff --git a/app/admin/events/page.tsx b/app/admin/events/page.tsx index 4364d66..79e4fb2 100644 --- a/app/admin/events/page.tsx +++ b/app/admin/events/page.tsx @@ -25,6 +25,11 @@ const weekdays = [ "Saturday", ] as const; +const cancellationDateFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "long", + timeZone: "UTC", +}); + interface RecurrenceSummaryInput { startsAt: Date; recurrenceFrequency: "day" | "week" | "month" | "year" | null; @@ -133,6 +138,10 @@ function formatDateTime(date: Date, timeZone: string) { }).format(date); } +function formatCancellationDate(dateKey: string) { + return cancellationDateFormatter.format(new Date(`${dateKey}T12:00:00Z`)); +} + export default async function AdminEventsPage() { await requireAdminSession(); const pendingEvents = await getPendingEvents(); @@ -177,6 +186,9 @@ export default async function AdminEventsPage() { {pendingEvents.map((event) => { const titleId = `event-${event.id}-title`; const recurrenceSummary = formatRecurrenceSummary(event); + const canceledOccurrences = [ + ...event.canceledOccurrences, + ].sort(); return (
  • @@ -233,6 +245,24 @@ export default async function AdminEventsPage() {
    Recurrence
    {recurrenceSummary}
    + {canceledOccurrences.length > 0 && ( +
    +
    Canceled occurrences
    +
    +
      + {canceledOccurrences.map((date) => ( +
    • + +
    • + ))} +
    +
    +
    + )}
    Venue
    diff --git a/app/events/types.ts b/app/events/types.ts index b07e812..ad5e915 100644 --- a/app/events/types.ts +++ b/app/events/types.ts @@ -38,6 +38,7 @@ export interface RecurrenceRule { endType: "never" | "on_date" | "after_occurrences"; endDate: string | null; occurrenceCount: number | null; + excludedDates: string[]; } export interface Event { diff --git a/db/migrations.test.ts b/db/migrations.test.ts index ef567dd..26ade45 100644 --- a/db/migrations.test.ts +++ b/db/migrations.test.ts @@ -37,7 +37,7 @@ describe("Netlify Database migrations", () => { await database.stop(); }); - it("creates the Better Auth, event, and recurrence tables", async () => { + it("creates the Better Auth, event, recurrence, and cancellation tables", async () => { const result = await database.query<{ table_name: string }>( `SELECT table_name FROM information_schema.tables @@ -49,6 +49,7 @@ describe("Netlify Database migrations", () => { expect.arrayContaining([ "account", "event", + "event_occurrence_cancellation", "event_recurrence", "session", "user", @@ -57,6 +58,34 @@ describe("Netlify Database migrations", () => { ); }); + it("adds event-series cancellation columns", async () => { + const result = await database.query<{ + column_name: string; + data_type: string; + is_nullable: string; + }>(` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'event' + AND column_name IN ('canceled_at', 'canceled_by') + ORDER BY column_name + `); + + expect(result.rows).toEqual([ + { + column_name: "canceled_at", + data_type: "timestamp with time zone", + is_nullable: "YES", + }, + { + column_name: "canceled_by", + data_type: "text", + is_nullable: "YES", + }, + ]); + }); + it("defaults new events to pending", async () => { await database.exec(` INSERT INTO "user" (id, name, email) @@ -295,4 +324,36 @@ describe("Netlify Database migrations", () => { await database.exec(`DELETE FROM "event" WHERE id = '${eventId}'`); }); + + it("stores one cancellation per event occurrence and cascades on event deletion", async () => { + const eventId = "10000000-0000-4000-8000-000000000003"; + await insertEvent(eventId, "Occurrence Cancellation Test"); + await database.exec(` + INSERT INTO "event_occurrence_cancellation" ( + event_id, + occurrence_date + ) + VALUES ('${eventId}', '2026-09-08') + `); + + await expect( + database.exec(` + INSERT INTO "event_occurrence_cancellation" ( + event_id, + occurrence_date + ) + VALUES ('${eventId}', '2026-09-08') + `), + ).rejects.toThrow(); + + await database.exec(`DELETE FROM "event" WHERE id = '${eventId}'`); + + const remaining = await database.query<{ count: number }>(` + SELECT COUNT(*)::integer AS count + FROM "event_occurrence_cancellation" + WHERE event_id = '${eventId}' + `); + + expect(remaining.rows).toEqual([{ count: 0 }]); + }); }); diff --git a/db/schema.ts b/db/schema.ts index b5d4022..00cdece 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -8,6 +8,7 @@ import { pgTable, text, timestamp, + uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -76,6 +77,13 @@ export const event = pgTable( mode: "date", withTimezone: true, }), + canceledAt: timestamp("canceled_at", { + mode: "date", + withTimezone: true, + }), + canceledBy: text("canceled_by").references(() => user.id, { + onDelete: "set null", + }), createdAt: timestamp("created_at", { mode: "date", withTimezone: true, @@ -175,8 +183,37 @@ export const eventRecurrence = pgTable( ], ); +export const eventOccurrenceCancellation = pgTable( + "event_occurrence_cancellation", + { + id: uuid("id").defaultRandom().primaryKey(), + eventId: uuid("event_id") + .notNull() + .references(() => event.id, { onDelete: "cascade" }), + occurrenceDate: date("occurrence_date", { mode: "string" }).notNull(), + canceledBy: text("canceled_by").references(() => user.id, { + onDelete: "set null", + }), + createdAt: timestamp("created_at", { + mode: "date", + withTimezone: true, + }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("event_occurrence_cancellation_event_date_uidx").on( + table.eventId, + table.occurrenceDate, + ), + index("event_occurrence_cancellation_event_id_idx").on(table.eventId), + ], +); + export type EventRow = typeof event.$inferSelect; export type EventRecurrenceRow = typeof eventRecurrence.$inferSelect; +export type EventOccurrenceCancellationRow = + typeof eventOccurrenceCancellation.$inferSelect; export type EventMode = (typeof eventMode.enumValues)[number]; export type EventStatus = (typeof eventStatus.enumValues)[number]; export type RecurrenceFrequency = diff --git a/lib/events/actions.ts b/lib/events/actions.ts index 705baa5..a90b5ab 100644 --- a/lib/events/actions.ts +++ b/lib/events/actions.ts @@ -1,21 +1,42 @@ "use server"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/db"; -import { event, eventRecurrence } from "@/db/schema"; +import { + event, + eventOccurrenceCancellation, + eventRecurrence, +} from "@/db/schema"; +import type { RecurrenceRule } from "@/app/events/types"; import { getCurrentSession, sessionIsAdmin } from "@/lib/session"; import type { + CancellationFormState, EventFormState, ModerationFormState, } from "@/lib/events/state"; +import { + getOccurrencesInRange, + getSacramentoDateKey, +} from "@/lib/events/recurrence"; import { SACRAMENTO_TIMEZONE, validateEventSubmission, validateModeration, } from "@/lib/events/validation"; +const cancellationSchema = z.discriminatedUnion("scope", [ + z.object({ + occurrenceDate: z.unknown().optional(), + scope: z.literal("event"), + }), + z.object({ + occurrenceDate: z.iso.date("Choose a valid occurrence date."), + scope: z.literal("occurrence"), + }), +]); + export async function submitEvent( _previousState: EventFormState, formData: FormData, @@ -117,13 +138,19 @@ export async function moderateEvent( status: moderation.data.decision, updatedAt: new Date(), }) - .where(and(eq(event.id, id.data), eq(event.status, "pending"))) + .where( + and( + eq(event.id, id.data), + eq(event.status, "pending"), + isNull(event.canceledAt), + ), + ) .returning({ id: event.id }); if (!updatedEvent) { return { status: "error", - message: "This event has already been reviewed.", + message: "This event has already been reviewed or canceled.", }; } } catch (error) { @@ -146,3 +173,200 @@ export async function moderateEvent( : "Event rejected. The submitter can now see your note.", }; } + +export async function cancelEvent( + eventId: string, + _previousState: CancellationFormState, + formData: FormData, +): Promise { + const session = await getCurrentSession(); + + if (!session) { + return { + status: "error", + message: "Your session expired. Sign in again before canceling.", + }; + } + + const id = z.uuid().safeParse(eventId); + const cancellation = cancellationSchema.safeParse({ + occurrenceDate: formData.get("occurrenceDate") ?? undefined, + scope: formData.get("scope"), + }); + + if (!id.success) { + return { + status: "error", + message: "That event could not be found.", + }; + } + + if (!cancellation.success) { + return { + status: "error", + message: + cancellation.error.issues[0]?.message ?? + "Choose what you want to cancel.", + }; + } + + try { + const result = await db.transaction(async (transaction) => { + const [ownedEvent] = await transaction + .select({ + canceledAt: event.canceledAt, + recurrenceCount: eventRecurrence.occurrenceCount, + recurrenceEndDate: eventRecurrence.endDate, + recurrenceEndType: eventRecurrence.endType, + recurrenceFrequency: eventRecurrence.frequency, + recurrenceInterval: eventRecurrence.interval, + recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, + recurrenceWeekdays: eventRecurrence.weekdays, + startsAt: event.startsAt, + }) + .from(event) + .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) + .where( + and( + eq(event.id, id.data), + eq(event.submittedBy, session.user.id), + ), + ) + .limit(1) + .for("update", { of: event }); + + if (!ownedEvent) { + return { + status: "error", + message: "That event could not be found in your submissions.", + } satisfies CancellationFormState; + } + + if (ownedEvent.canceledAt) { + return { + status: "error", + message: "This event series is already canceled.", + } satisfies CancellationFormState; + } + + if (cancellation.data.scope === "event") { + const canceledAt = new Date(); + const [updatedEvent] = await transaction + .update(event) + .set({ + canceledAt, + canceledBy: session.user.id, + updatedAt: canceledAt, + }) + .where( + and( + eq(event.id, id.data), + eq(event.submittedBy, session.user.id), + isNull(event.canceledAt), + ), + ) + .returning({ id: event.id }); + + if (!updatedEvent) { + return { + status: "error", + message: "This event series is already canceled.", + } satisfies CancellationFormState; + } + + return { + status: "success", + message: "Event canceled. It has been removed from the calendar.", + } satisfies CancellationFormState; + } + + const occurrenceDate = cancellation.data.occurrenceDate; + const currentSacramentoDate = getSacramentoDateKey(new Date()); + + if (occurrenceDate < currentSacramentoDate) { + return { + status: "error", + message: "Past event occurrences cannot be canceled.", + } satisfies CancellationFormState; + } + + if ( + !ownedEvent.recurrenceFrequency || + !ownedEvent.recurrenceInterval || + !ownedEvent.recurrenceEndType + ) { + return { + status: "error", + message: "Only repeating events have individual occurrences.", + } satisfies CancellationFormState; + } + + const recurrenceRule: RecurrenceRule = { + endDate: ownedEvent.recurrenceEndDate, + endType: ownedEvent.recurrenceEndType, + excludedDates: [], + frequency: ownedEvent.recurrenceFrequency, + interval: ownedEvent.recurrenceInterval, + monthlyPattern: ownedEvent.recurrenceMonthlyPattern, + occurrenceCount: ownedEvent.recurrenceCount, + weekdays: ownedEvent.recurrenceWeekdays, + }; + const [scheduledOccurrence] = getOccurrencesInRange( + ownedEvent.startsAt, + recurrenceRule, + occurrenceDate, + occurrenceDate, + ); + + if (!scheduledOccurrence) { + return { + status: "error", + message: "That date is not a scheduled occurrence of this event.", + } satisfies CancellationFormState; + } + + if (scheduledOccurrence <= new Date()) { + return { + status: "error", + message: "Only future event occurrences can be canceled.", + } satisfies CancellationFormState; + } + + const [createdCancellation] = await transaction + .insert(eventOccurrenceCancellation) + .values({ + canceledBy: session.user.id, + eventId: id.data, + occurrenceDate, + }) + .onConflictDoNothing() + .returning({ id: eventOccurrenceCancellation.id }); + + if (!createdCancellation) { + return { + status: "error", + message: "That event occurrence is already canceled.", + } satisfies CancellationFormState; + } + + return { + status: "success", + message: "Occurrence canceled. The rest of the series is unchanged.", + } satisfies CancellationFormState; + }); + + if (result.status === "success") { + revalidatePath("/events"); + revalidatePath("/account"); + revalidatePath("/admin/events"); + } + + return result; + } catch (error) { + console.error("Unable to cancel event", error); + return { + status: "error", + message: "We could not cancel this event right now. Please try again.", + }; + } +} diff --git a/lib/events/mapper.test.ts b/lib/events/mapper.test.ts index 864d88f..dee4825 100644 --- a/lib/events/mapper.test.ts +++ b/lib/events/mapper.test.ts @@ -5,6 +5,7 @@ import { } from "@/lib/events/mapper"; const baseEvent: ApprovedEventRecord = { + canceledOccurrenceDates: [], description: "A community event.", endsAt: new Date("2026-09-02T03:00:00.000Z"), eventUrl: "https://example.com/event", @@ -62,6 +63,7 @@ describe("mapApprovedEventsToCalendar", () => { expect(event.recurrence_rule).toEqual({ endDate: null, endType: "after_occurrences", + excludedDates: [], frequency: "week", interval: 2, monthlyPattern: null, @@ -69,4 +71,22 @@ describe("mapApprovedEventsToCalendar", () => { weekdays: [2, 4], }); }); + + it("maps one-off cancellation dates as recurrence exclusions", () => { + const [event] = mapApprovedEventsToCalendar([ + { + ...baseEvent, + canceledOccurrenceDates: ["2026-09-16", "2026-09-30"], + recurrenceEndType: "never", + recurrenceFrequency: "week", + recurrenceInterval: 1, + recurrenceWeekdays: [2], + }, + ]); + + expect(event.recurrence_rule?.excludedDates).toEqual([ + "2026-09-16", + "2026-09-30", + ]); + }); }); diff --git a/lib/events/mapper.ts b/lib/events/mapper.ts index aa450de..ddc9e94 100644 --- a/lib/events/mapper.ts +++ b/lib/events/mapper.ts @@ -5,6 +5,7 @@ import type { import type { EventMode } from "@/db/schema"; export interface ApprovedEventRecord { + canceledOccurrenceDates: string[]; description: string; endsAt: Date; eventUrl: string | null; @@ -37,6 +38,7 @@ export function mapApprovedEventsToCalendar( ? { endDate: row.recurrenceEndDate, endType: row.recurrenceEndType, + excludedDates: row.canceledOccurrenceDates, frequency: row.recurrenceFrequency, interval: row.recurrenceInterval, monthlyPattern: row.recurrenceMonthlyPattern, diff --git a/lib/events/queries.ts b/lib/events/queries.ts index 1a4316c..75da27d 100644 --- a/lib/events/queries.ts +++ b/lib/events/queries.ts @@ -1,91 +1,162 @@ import "server-only"; -import { asc, desc, eq } from "drizzle-orm"; +import { and, asc, desc, eq, isNull } from "drizzle-orm"; import { db } from "@/db"; import { user } from "@/db/auth-schema"; -import { event, eventRecurrence } from "@/db/schema"; +import { + event, + eventOccurrenceCancellation, + eventRecurrence, +} from "@/db/schema"; import type { Event as CalendarEvent } from "@/app/events/types"; import { mapApprovedEventsToCalendar } from "@/lib/events/mapper"; +function groupCanceledOccurrences( + rows: Array<{ eventId: string; occurrenceDate: string }>, +) { + const datesByEvent = new Map(); + + for (const row of rows) { + datesByEvent.set(row.eventId, [ + ...(datesByEvent.get(row.eventId) ?? []), + row.occurrenceDate, + ]); + } + + return datesByEvent; +} + export async function getApprovedEvents(): Promise { - const rows = await db - .select({ - description: event.description, - endsAt: event.endsAt, - eventUrl: event.eventUrl, - id: event.id, - locationAddress: event.locationAddress, - locationName: event.locationName, - mode: event.mode, - startsAt: event.startsAt, - timezone: event.timezone, - title: event.title, - recurrenceFrequency: eventRecurrence.frequency, - recurrenceInterval: eventRecurrence.interval, - recurrenceWeekdays: eventRecurrence.weekdays, - recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, - recurrenceEndType: eventRecurrence.endType, - recurrenceEndDate: eventRecurrence.endDate, - recurrenceCount: eventRecurrence.occurrenceCount, - }) - .from(event) - .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) - .where(eq(event.status, "approved")) - .orderBy(asc(event.startsAt)); + const [rows, cancellations] = await Promise.all([ + db + .select({ + description: event.description, + endsAt: event.endsAt, + eventUrl: event.eventUrl, + id: event.id, + locationAddress: event.locationAddress, + locationName: event.locationName, + mode: event.mode, + startsAt: event.startsAt, + timezone: event.timezone, + title: event.title, + recurrenceFrequency: eventRecurrence.frequency, + recurrenceInterval: eventRecurrence.interval, + recurrenceWeekdays: eventRecurrence.weekdays, + recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, + recurrenceEndType: eventRecurrence.endType, + recurrenceEndDate: eventRecurrence.endDate, + recurrenceCount: eventRecurrence.occurrenceCount, + }) + .from(event) + .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) + .where(and(eq(event.status, "approved"), isNull(event.canceledAt))) + .orderBy(asc(event.startsAt)), + db + .select({ + eventId: eventOccurrenceCancellation.eventId, + occurrenceDate: eventOccurrenceCancellation.occurrenceDate, + }) + .from(eventOccurrenceCancellation) + .innerJoin(event, eq(eventOccurrenceCancellation.eventId, event.id)) + .where(and(eq(event.status, "approved"), isNull(event.canceledAt))) + .orderBy(asc(eventOccurrenceCancellation.occurrenceDate)), + ]); + const cancellationsByEvent = groupCanceledOccurrences(cancellations); - return mapApprovedEventsToCalendar(rows); + return mapApprovedEventsToCalendar( + rows.map((row) => ({ + ...row, + canceledOccurrenceDates: cancellationsByEvent.get(row.id) ?? [], + })), + ); } export async function getSubmissionsForUser(userId: string) { - return db - .select({ - createdAt: event.createdAt, - endsAt: event.endsAt, - id: event.id, - moderationNote: event.moderationNote, - startsAt: event.startsAt, - status: event.status, - title: event.title, - recurrenceFrequency: eventRecurrence.frequency, - recurrenceInterval: eventRecurrence.interval, - recurrenceWeekdays: eventRecurrence.weekdays, - recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, - recurrenceEndType: eventRecurrence.endType, - recurrenceEndDate: eventRecurrence.endDate, - recurrenceCount: eventRecurrence.occurrenceCount, - }) - .from(event) - .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) - .where(eq(event.submittedBy, userId)) - .orderBy(desc(event.createdAt)); + const [rows, cancellations] = await Promise.all([ + db + .select({ + canceledAt: event.canceledAt, + createdAt: event.createdAt, + endsAt: event.endsAt, + id: event.id, + moderationNote: event.moderationNote, + startsAt: event.startsAt, + status: event.status, + title: event.title, + recurrenceFrequency: eventRecurrence.frequency, + recurrenceInterval: eventRecurrence.interval, + recurrenceWeekdays: eventRecurrence.weekdays, + recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, + recurrenceEndType: eventRecurrence.endType, + recurrenceEndDate: eventRecurrence.endDate, + recurrenceCount: eventRecurrence.occurrenceCount, + }) + .from(event) + .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) + .where(eq(event.submittedBy, userId)) + .orderBy(desc(event.createdAt)), + db + .select({ + eventId: eventOccurrenceCancellation.eventId, + occurrenceDate: eventOccurrenceCancellation.occurrenceDate, + }) + .from(eventOccurrenceCancellation) + .innerJoin(event, eq(eventOccurrenceCancellation.eventId, event.id)) + .where(eq(event.submittedBy, userId)) + .orderBy(asc(eventOccurrenceCancellation.occurrenceDate)), + ]); + const cancellationsByEvent = groupCanceledOccurrences(cancellations); + + return rows.map((row) => ({ + ...row, + canceledOccurrences: cancellationsByEvent.get(row.id) ?? [], + })); } export async function getPendingEvents() { - return db - .select({ - createdAt: event.createdAt, - description: event.description, - endsAt: event.endsAt, - eventUrl: event.eventUrl, - id: event.id, - locationAddress: event.locationAddress, - locationName: event.locationName, - mode: event.mode, - startsAt: event.startsAt, - submitterEmail: user.email, - submitterName: user.name, - timezone: event.timezone, - title: event.title, - recurrenceFrequency: eventRecurrence.frequency, - recurrenceInterval: eventRecurrence.interval, - recurrenceWeekdays: eventRecurrence.weekdays, - recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, - recurrenceEndType: eventRecurrence.endType, - recurrenceEndDate: eventRecurrence.endDate, - recurrenceCount: eventRecurrence.occurrenceCount, - }) - .from(event) - .leftJoin(user, eq(event.submittedBy, user.id)) - .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) - .where(eq(event.status, "pending")) - .orderBy(asc(event.createdAt)); + const [rows, cancellations] = await Promise.all([ + db + .select({ + createdAt: event.createdAt, + description: event.description, + endsAt: event.endsAt, + eventUrl: event.eventUrl, + id: event.id, + locationAddress: event.locationAddress, + locationName: event.locationName, + mode: event.mode, + startsAt: event.startsAt, + submitterEmail: user.email, + submitterName: user.name, + timezone: event.timezone, + title: event.title, + recurrenceFrequency: eventRecurrence.frequency, + recurrenceInterval: eventRecurrence.interval, + recurrenceWeekdays: eventRecurrence.weekdays, + recurrenceMonthlyPattern: eventRecurrence.monthlyPattern, + recurrenceEndType: eventRecurrence.endType, + recurrenceEndDate: eventRecurrence.endDate, + recurrenceCount: eventRecurrence.occurrenceCount, + }) + .from(event) + .leftJoin(user, eq(event.submittedBy, user.id)) + .leftJoin(eventRecurrence, eq(event.id, eventRecurrence.eventId)) + .where(and(eq(event.status, "pending"), isNull(event.canceledAt))) + .orderBy(asc(event.createdAt)), + db + .select({ + eventId: eventOccurrenceCancellation.eventId, + occurrenceDate: eventOccurrenceCancellation.occurrenceDate, + }) + .from(eventOccurrenceCancellation) + .innerJoin(event, eq(eventOccurrenceCancellation.eventId, event.id)) + .where(and(eq(event.status, "pending"), isNull(event.canceledAt))) + .orderBy(asc(eventOccurrenceCancellation.occurrenceDate)), + ]); + const cancellationsByEvent = groupCanceledOccurrences(cancellations); + + return rows.map((row) => ({ + ...row, + canceledOccurrences: cancellationsByEvent.get(row.id) ?? [], + })); } diff --git a/lib/events/recurrence.test.ts b/lib/events/recurrence.test.ts index d193623..17d78b4 100644 --- a/lib/events/recurrence.test.ts +++ b/lib/events/recurrence.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import type { RecurrenceRule } from "@/app/events/types"; import { + getNextFutureOccurrence, getNextOccurrence, getOccurrenceEnd, getOccurrencesInRange, + getSacramentoDateKey, } from "@/lib/events/recurrence"; function recurrenceRule( @@ -12,6 +14,7 @@ function recurrenceRule( return { endDate: null, endType: "never", + excludedDates: [], frequency: "day", interval: 1, monthlyPattern: null, @@ -25,7 +28,32 @@ function isoDates(dates: Date[]) { return dates.map((date) => date.toISOString()); } +describe("getSacramentoDateKey", () => { + it("uses the Sacramento date near a UTC day boundary", () => { + expect(getSacramentoDateKey(new Date("2026-01-01T07:30:00.000Z"))).toBe( + "2025-12-31", + ); + }); +}); + describe("getOccurrencesInRange", () => { + it("omits excluded dates without extending an occurrence-count series", () => { + const occurrences = getOccurrencesInRange( + new Date("2026-01-01T18:00:00.000Z"), + recurrenceRule({ + endType: "after_occurrences", + excludedDates: ["2026-01-02"], + occurrenceCount: 3, + }), + "2026-01-01", + "2026-01-10", + ); + + expect(isoDates(occurrences)).toEqual([ + "2026-01-01T18:00:00.000Z", + "2026-01-03T18:00:00.000Z", + ]); + }); it("preserves a daily wall-clock start across the PST to PDT change", () => { const occurrences = getOccurrencesInRange( new Date("2026-03-07T18:00:00.000Z"), @@ -233,6 +261,16 @@ describe("getOccurrencesInRange", () => { }); describe("getNextOccurrence", () => { + it("skips a canceled occurrence on the reference date", () => { + const occurrence = getNextOccurrence( + new Date("2026-01-01T18:00:00.000Z"), + recurrenceRule({ excludedDates: ["2026-01-08"] }), + "2026-01-08", + ); + + expect(occurrence?.toISOString()).toBe("2026-01-09T18:00:00.000Z"); + }); + it("returns an occurrence on the reference date", () => { const occurrence = getNextOccurrence( new Date("2026-01-01T18:00:00.000Z"), @@ -267,6 +305,28 @@ describe("getNextOccurrence", () => { }); }); +describe("getNextFutureOccurrence", () => { + it("keeps a later occurrence on the reference day", () => { + const occurrence = getNextFutureOccurrence( + new Date("2026-08-19T18:00:00.000Z"), + recurrenceRule(), + new Date("2026-08-19T17:59:00.000Z"), + ); + + expect(occurrence?.toISOString()).toBe("2026-08-19T18:00:00.000Z"); + }); + + it("skips an occurrence that has already started today", () => { + const occurrence = getNextFutureOccurrence( + new Date("2026-08-19T18:00:00.000Z"), + recurrenceRule(), + new Date("2026-08-19T18:00:00.000Z"), + ); + + expect(occurrence?.toISOString()).toBe("2026-08-20T18:00:00.000Z"); + }); +}); + describe("getOccurrenceEnd", () => { it("preserves the seed wall-clock end when an occurrence crosses into PDT", () => { const end = getOccurrenceEnd( diff --git a/lib/events/recurrence.ts b/lib/events/recurrence.ts index 69a17f6..e955366 100644 --- a/lib/events/recurrence.ts +++ b/lib/events/recurrence.ts @@ -284,6 +284,10 @@ function validateRule(rule: RecurrenceRule) { "An after-occurrences recurrence must include a positive count.", ); } + + for (const excludedDate of rule.excludedDates) { + parseDateKey(excludedDate, "Excluded occurrence date"); + } } function getNthWeekdayDate( @@ -368,6 +372,7 @@ function enumerateOccurrences( : Number.POSITIVE_INFINITY; let occurrenceNumber = 0; let shouldStop = false; + const excludedDates = new Set(rule.excludedDates); const considerDate = (date: LocalDate) => { if (compareLocalDates(date, startDate) < 0) { @@ -392,6 +397,12 @@ function enumerateOccurrences( return; } + // An exception removes a scheduled date from display without changing its + // position in an occurrence-count-limited series. + if (excludedDates.has(formatDateKey(date))) { + return; + } + if ( compareLocalDates(date, minimumDate) >= 0 && visit(occurrence) @@ -467,6 +478,11 @@ function enumerateOccurrences( } } +/** Formats an instant as its Sacramento-local calendar date. */ +export function getSacramentoDateKey(date: Date): string { + return formatDateKey(toLocalDate(date)); +} + /** * Expands a recurrence into Sacramento-local calendar dates, inclusive of both * range boundaries. A Date boundary is interpreted in Sacramento time. @@ -523,6 +539,30 @@ export function getNextOccurrence( return nextOccurrence; } +/** Returns the first not-yet-started occurrence after an instant. */ +export function getNextFutureOccurrence( + startsAt: Date, + rule: RecurrenceRule, + referenceInstant: Date, +): Date | null { + const referenceDate = toLocalDate(referenceInstant); + const candidate = getNextOccurrence( + startsAt, + rule, + formatDateKey(referenceDate), + ); + + if (!candidate || candidate > referenceInstant) { + return candidate; + } + + return getNextOccurrence( + startsAt, + rule, + formatDateKey(addDays(referenceDate, 1)), + ); +} + /** * Reapplies the seed event's Sacramento-local calendar and wall-clock duration * to an expanded occurrence. If the mapped end falls in the spring-forward diff --git a/lib/events/state.ts b/lib/events/state.ts index f70a433..91d1655 100644 --- a/lib/events/state.ts +++ b/lib/events/state.ts @@ -27,6 +27,11 @@ export interface ModerationFormState { message: string; } +export interface CancellationFormState { + status: "idle" | "error" | "success"; + message: string; +} + export const initialEventFormState: EventFormState = { status: "idle", message: "", @@ -36,3 +41,8 @@ export const initialModerationFormState: ModerationFormState = { status: "idle", message: "", }; + +export const initialCancellationFormState: CancellationFormState = { + status: "idle", + message: "", +}; diff --git a/netlify/database/migrations/20260819071633_young_scourge/migration.sql b/netlify/database/migrations/20260819071633_young_scourge/migration.sql new file mode 100644 index 0000000..0900006 --- /dev/null +++ b/netlify/database/migrations/20260819071633_young_scourge/migration.sql @@ -0,0 +1,15 @@ +CREATE TABLE "event_occurrence_cancellation" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "event_id" uuid NOT NULL, + "occurrence_date" date NOT NULL, + "canceled_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "event" ADD COLUMN "canceled_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "event" ADD COLUMN "canceled_by" text;--> statement-breakpoint +CREATE UNIQUE INDEX "event_occurrence_cancellation_event_date_uidx" ON "event_occurrence_cancellation" ("event_id","occurrence_date");--> statement-breakpoint +CREATE INDEX "event_occurrence_cancellation_event_id_idx" ON "event_occurrence_cancellation" ("event_id");--> statement-breakpoint +ALTER TABLE "event" ADD CONSTRAINT "event_canceled_by_user_id_fkey" FOREIGN KEY ("canceled_by") REFERENCES "user"("id") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "event_occurrence_cancellation" ADD CONSTRAINT "event_occurrence_cancellation_event_id_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "event"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "event_occurrence_cancellation" ADD CONSTRAINT "event_occurrence_cancellation_canceled_by_user_id_fkey" FOREIGN KEY ("canceled_by") REFERENCES "user"("id") ON DELETE SET NULL; \ No newline at end of file diff --git a/netlify/database/migrations/20260819071633_young_scourge/snapshot.json b/netlify/database/migrations/20260819071633_young_scourge/snapshot.json new file mode 100644 index 0000000..eb79312 --- /dev/null +++ b/netlify/database/migrations/20260819071633_young_scourge/snapshot.json @@ -0,0 +1,1524 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "5a6d604d-a4a9-4aee-a372-95ecd6232cde", + "prevIds": [ + "eedfdd7d-34a9-4bee-af5f-58f2771757cc" + ], + "ddl": [ + { + "values": [ + "online", + "in_person", + "hybrid" + ], + "name": "event_mode", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "pending", + "approved", + "rejected" + ], + "name": "event_status", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "never", + "on_date", + "after_occurrences" + ], + "name": "recurrence_end_type", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "day", + "week", + "month", + "year" + ], + "name": "recurrence_frequency", + "entityType": "enums", + "schema": "public" + }, + { + "values": [ + "day_of_month", + "nth_weekday" + ], + "name": "recurrence_monthly_pattern", + "entityType": "enums", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "account", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "session", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "user", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "verification", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "event", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "event_occurrence_cancellation", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "event_recurrence", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "issuer", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "account" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "impersonated_by", + "entityType": "columns", + "schema": "public", + "table": "session" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "role", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "banned", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ban_reason", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ban_expires", + "entityType": "columns", + "schema": "public", + "table": "user" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "verification" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "submitted_by", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "varchar(160)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "starts_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ends_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'America/Los_Angeles'", + "generated": null, + "identity": null, + "name": "timezone", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "event_mode", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "mode", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "varchar(200)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location_name", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "location_address", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_url", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "event_status", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": "'pending'", + "generated": null, + "identity": null, + "name": "status", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "varchar(500)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "moderation_note", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewed_by", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "reviewed_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_by", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "event" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "gen_random_uuid()", + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "type": "date", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurrence_date", + "entityType": "columns", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "canceled_by", + "entityType": "columns", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "event_id", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "recurrence_frequency", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "frequency", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "interval", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 1, + "default": null, + "generated": null, + "identity": null, + "name": "weekdays", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "recurrence_monthly_pattern", + "typeSchema": "public", + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "monthly_pattern", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "recurrence_end_type", + "typeSchema": "public", + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_type", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "date", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "end_date", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "occurrence_count", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "event_recurrence" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "issuer", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "account_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_issuer_accountId_uidx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "entityType": "indexes", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "entityType": "indexes", + "schema": "public", + "table": "verification" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "status", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "starts_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "event_status_starts_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "submitted_by", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "event_submitted_by_created_at_idx", + "entityType": "indexes", + "schema": "public", + "table": "event" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "occurrence_date", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "event_occurrence_cancellation_event_date_uidx", + "entityType": "indexes", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "event_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "event_occurrence_cancellation_event_id_idx", + "entityType": "indexes", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "account" + }, + { + "nameExplicit": false, + "columns": [ + "user_id" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "session" + }, + { + "nameExplicit": false, + "columns": [ + "submitted_by" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "event_submitted_by_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event" + }, + { + "nameExplicit": false, + "columns": [ + "reviewed_by" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "event_reviewed_by_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event" + }, + { + "nameExplicit": false, + "columns": [ + "canceled_by" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "event_canceled_by_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event" + }, + { + "nameExplicit": false, + "columns": [ + "event_id" + ], + "schemaTo": "public", + "tableTo": "event", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "event_occurrence_cancellation_event_id_event_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "nameExplicit": false, + "columns": [ + "canceled_by" + ], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "event_occurrence_cancellation_canceled_by_user_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event_occurrence_cancellation" + }, + { + "nameExplicit": false, + "columns": [ + "event_id" + ], + "schemaTo": "public", + "tableTo": "event", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "event_recurrence_event_id_event_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "event_recurrence" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pkey", + "schema": "public", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_occurrence_cancellation_pkey", + "schema": "public", + "table": "event_occurrence_cancellation", + "entityType": "pks" + }, + { + "columns": [ + "event_id" + ], + "nameExplicit": false, + "name": "event_recurrence_pkey", + "schema": "public", + "table": "event_recurrence", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": [ + "token" + ], + "nullsNotDistinct": false, + "name": "session_token_key", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": [ + "email" + ], + "nullsNotDistinct": false, + "name": "user_email_key", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "value": "\"ends_at\" > \"starts_at\"", + "name": "event_ends_after_start", + "entityType": "checks", + "schema": "public", + "table": "event" + }, + { + "value": "\"interval\" BETWEEN 1 AND 99", + "name": "event_recurrence_interval_range", + "entityType": "checks", + "schema": "public", + "table": "event_recurrence" + }, + { + "value": "(\n\t\t\t\t\"frequency\" = 'week'\n\t\t\t\tAND \"weekdays\" IS NOT NULL\n\t\t\t\tAND array_ndims(\"weekdays\") = 1\n\t\t\t\tAND cardinality(\"weekdays\") BETWEEN 1 AND 7\n\t\t\t\tAND array_position(\"weekdays\", NULL) IS NULL\n\t\t\t\tAND \"weekdays\" <@ ARRAY[0, 1, 2, 3, 4, 5, 6]::integer[]\n\t\t\t) OR (\n\t\t\t\t\"frequency\" <> 'week'\n\t\t\t\tAND \"weekdays\" IS NULL\n\t\t\t)", + "name": "event_recurrence_weekdays_consistency", + "entityType": "checks", + "schema": "public", + "table": "event_recurrence" + }, + { + "value": "(\n\t\t\t\t\"frequency\" = 'month'\n\t\t\t\tAND \"monthly_pattern\" IS NOT NULL\n\t\t\t) OR (\n\t\t\t\t\"frequency\" <> 'month'\n\t\t\t\tAND \"monthly_pattern\" IS NULL\n\t\t\t)", + "name": "event_recurrence_monthly_pattern_consistency", + "entityType": "checks", + "schema": "public", + "table": "event_recurrence" + }, + { + "value": "(\n\t\t\t\t\"end_type\" = 'never'\n\t\t\t\tAND \"end_date\" IS NULL\n\t\t\t\tAND \"occurrence_count\" IS NULL\n\t\t\t) OR (\n\t\t\t\t\"end_type\" = 'on_date'\n\t\t\t\tAND \"end_date\" IS NOT NULL\n\t\t\t\tAND \"occurrence_count\" IS NULL\n\t\t\t) OR (\n\t\t\t\t\"end_type\" = 'after_occurrences'\n\t\t\t\tAND \"end_date\" IS NULL\n\t\t\t\tAND \"occurrence_count\" IS NOT NULL\n\t\t\t\tAND \"occurrence_count\" BETWEEN 2 AND 1000\n\t\t\t)", + "name": "event_recurrence_end_consistency", + "entityType": "checks", + "schema": "public", + "table": "event_recurrence" + } + ], + "renames": [] +} \ No newline at end of file -- 2.51.2