+ Canceled on {dateFormatter.format(submission.canceledAt)}.
+
+ )}
+ {canceledOccurrences.length > 0 && (
+
@@ -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