Files
famapp/src/modules/calendar/server/queries.ts
T
ginnoir e1c2a090fb feat: p2 batch 28-31 reminders lists comments bang stats
Calendar events support multiple reminder offsets with presets and per-user defaults.

Lists index adds inline task entry and list property editing.

Generic entity comments on list detail. New bangs.stats dashboard widget.

Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
2026-07-04 22:17:35 -05:00

292 lines
8.4 KiB
TypeScript

"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<CalendarDto[]> {
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<CalendarDto[]> {
const { user, household } = await getCurrentSession();
return listCalendarsForScope({ householdId: household.id, userId: user.id });
}
export async function getCalendarForScope(scope: ApiScope, id: string): Promise<CalendarDto> {
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<CalendarEventDto[]> {
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<CalendarEventDto[]> {
const { user, household } = await getCurrentSession();
return listEventsForScope({ householdId: household.id, userId: user.id }, input);
}
export async function getEventForScope(scope: ApiScope, id: string): Promise<CalendarEventDto> {
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<number[]> {
const { user } = await getCurrentSession();
return normalizeReminderOffsets(user.defaultEventReminderOffsets ?? [30]);
}
export async function getEventReminderOffsets(eventId: string): Promise<number[]> {
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(),
};
}