"use server"; import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { normalizeReminderOffsets } from "@/lib/reminder-offsets"; import { listReminderOffsets } from "@/modules/_core/reminders"; import { householdMembers } from "@/modules/_core/schema"; import { calendarEvents, calendars } from "../schema"; export type ApiScope = { householdId: string; userId: string | null; }; export type CalendarDto = { id: string; name: string; color: string | null; visibility: "private" | "household"; ownerId: string; }; export type CalendarEventDto = { id: string; calendarId: string; title: string; startAt: string; endAt: string; allDay: boolean; location: string | null; notes: string | null; }; const listEventsSchema = z.object({ from: z.coerce.date(), to: z.coerce.date(), calendarIds: z.union([z.literal("all"), z.array(z.string().uuid())]), }); function calendarVisibilityFilter(scope: ApiScope) { if (scope.userId) { return or(eq(calendars.visibility, "household"), eq(calendars.ownerId, scope.userId)); } return eq(calendars.visibility, "household"); } export async function canSeeCalendarForScope(scope: ApiScope, calendarId: string) { const [row] = await db .select({ visibility: calendars.visibility, ownerId: calendars.ownerId, householdId: calendars.householdId, }) .from(calendars) .where(eq(calendars.id, calendarId)) .limit(1); if (!row || row.householdId !== scope.householdId) return false; if (row.visibility === "household") return true; if (!scope.userId) return false; return row.ownerId === scope.userId; } export async function canSeeCalendar(userId: string, calendarId: string) { const [row] = await db .select({ calendarId: calendars.id, visibility: calendars.visibility, ownerId: calendars.ownerId, memberUserId: householdMembers.userId, }) .from(calendars) .leftJoin( householdMembers, and( eq(householdMembers.householdId, calendars.householdId), eq(householdMembers.userId, userId), ), ) .where(eq(calendars.id, calendarId)) .limit(1); if (!row) return false; if (row.visibility === "private") return row.ownerId === userId; return row.memberUserId === userId; } export async function listCalendarsForScope(scope: ApiScope): Promise { const rows = await db .select({ id: calendars.id, name: calendars.name, color: calendars.color, visibility: calendars.visibility, ownerId: calendars.ownerId, }) .from(calendars) .where(and(eq(calendars.householdId, scope.householdId), calendarVisibilityFilter(scope))) .orderBy(asc(calendars.name)); return rows.map((row) => ({ ...row, visibility: row.visibility as "private" | "household", })); } export async function listCalendars(): Promise { const { user, household } = await getCurrentSession(); return listCalendarsForScope({ householdId: household.id, userId: user.id }); } export async function getCalendarForScope(scope: ApiScope, id: string): Promise { const parsed = z.string().uuid().parse(id); const [row] = await db .select({ id: calendars.id, name: calendars.name, color: calendars.color, visibility: calendars.visibility, ownerId: calendars.ownerId, householdId: calendars.householdId, }) .from(calendars) .where(and(eq(calendars.id, parsed), eq(calendars.householdId, scope.householdId))) .limit(1); if (!row) throw new Error("Calendar not found"); if (!(await canSeeCalendarForScope(scope, row.id))) throw new Error("Calendar not found"); return { id: row.id, name: row.name, color: row.color, visibility: row.visibility as "private" | "household", ownerId: row.ownerId, }; } export async function listEventsForScope( scope: ApiScope, input: { from: Date | string; to: Date | string; calendarIds: "all" | string[]; }, ): Promise { const parsed = listEventsSchema.parse(input); const visibleCalendars = await listCalendarsForScope(scope); const visibleIds = new Set(visibleCalendars.map((calendar) => calendar.id)); const calendarIds = parsed.calendarIds === "all" ? [...visibleIds] : parsed.calendarIds.filter((id) => visibleIds.has(id)); if (calendarIds.length === 0) return []; const rows = await db .select({ id: calendarEvents.id, calendarId: calendarEvents.calendarId, title: calendarEvents.title, startAt: calendarEvents.startAt, endAt: calendarEvents.endAt, allDay: calendarEvents.allDay, location: calendarEvents.location, notes: calendarEvents.notes, }) .from(calendarEvents) .where( and( inArray(calendarEvents.calendarId, calendarIds), lte(calendarEvents.startAt, parsed.to), gte(calendarEvents.endAt, parsed.from), ), ) .orderBy(asc(calendarEvents.startAt)); return rows.map(toEventDto); } export async function listEvents(input: { from: Date | string; to: Date | string; calendarIds: "all" | string[]; }): Promise { const { user, household } = await getCurrentSession(); return listEventsForScope({ householdId: household.id, userId: user.id }, input); } export async function getEventForScope(scope: ApiScope, id: string): Promise { const parsed = z.string().uuid().parse(id); const [row] = await db .select({ id: calendarEvents.id, calendarId: calendarEvents.calendarId, title: calendarEvents.title, startAt: calendarEvents.startAt, endAt: calendarEvents.endAt, allDay: calendarEvents.allDay, location: calendarEvents.location, notes: calendarEvents.notes, householdId: calendars.householdId, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where(and(eq(calendarEvents.id, parsed), eq(calendars.householdId, scope.householdId))) .limit(1); if (!row) throw new Error("Event not found"); if (!(await canSeeCalendarForScope(scope, row.calendarId))) throw new Error("Event not found"); return toEventDto(row); } export async function searchCalendars(query: string, householdId: string) { const rows = await db .select({ id: calendars.id, name: calendars.name }) .from(calendars) .where( and(eq(calendars.householdId, householdId), sql`${calendars.name} ilike ${`%${query}%`}`), ) .limit(10); return rows.map((row) => ({ id: row.id, title: row.name, url: `/calendar?id=${row.id}`, })); } export async function searchEvents(query: string, householdId: string) { const rows = await db .select({ id: calendarEvents.id, title: calendarEvents.title, location: calendarEvents.location, notes: calendarEvents.notes, }) .from(calendarEvents) .innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id)) .where( and( eq(calendars.householdId, householdId), or( sql`${calendarEvents.title} ilike ${`%${query}%`}`, sql`${calendarEvents.location} ilike ${`%${query}%`}`, sql`${calendarEvents.notes} ilike ${`%${query}%`}`, ), ), ) .limit(10); return rows.map((row) => ({ id: row.id, title: row.title, url: `/calendar/events/${row.id}`, excerpt: row.location ?? row.notes ?? undefined, })); } export async function getDefaultEventReminderOffsets(): Promise { const { user } = await getCurrentSession(); return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]); } export async function getEventReminderOffsets(eventId: string): Promise { const parsed = z.string().uuid().parse(eventId); const { user, household } = await getCurrentSession(); const event = await getEventForScope({ householdId: household.id, userId: user.id }, parsed); if (!event) throw new Error("Event not found"); return listReminderOffsets("calendar.event", parsed); } function toEventDto(row: { id: string; calendarId: string; title: string; startAt: Date; endAt: Date; allDay: boolean; location: string | null; notes: string | null; }): CalendarEventDto { return { ...row, startAt: row.startAt.toISOString(), endAt: row.endAt.toISOString(), }; }