Implement calendar module

This commit is contained in:
ginnoir
2026-05-06 03:10:28 -05:00
parent 8cc2ef0732
commit 744c1119a9
18 changed files with 1414 additions and 32 deletions
+17
View File
@@ -0,0 +1,17 @@
import { CalendarShell } from "@/modules/calendar/components/calendar-shell";
import { listCalendars, listEvents } from "@/modules/calendar/server/queries";
export default async function CalendarPage() {
const now = new Date();
const from = new Date(now);
from.setMonth(from.getMonth() - 2);
const to = new Date(now);
to.setMonth(to.getMonth() + 10);
const [calendars, events] = await Promise.all([
listCalendars(),
listEvents({ from, to, calendarIds: "all" }),
]);
return <CalendarShell calendars={calendars} events={events} />;
}
+38 -1
View File
@@ -197,4 +197,41 @@
html {
@apply font-sans;
}
}
}
.fc {
--fc-border-color: var(--border);
--fc-page-bg-color: var(--background);
--fc-neutral-bg-color: var(--muted);
--fc-neutral-text-color: var(--muted-foreground);
--fc-button-bg-color: var(--primary);
--fc-button-border-color: var(--primary);
--fc-button-text-color: var(--primary-foreground);
--fc-button-hover-bg-color: var(--foreground);
--fc-button-hover-border-color: var(--foreground);
color: var(--foreground);
}
.fc .fc-button {
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
padding: 0.35rem 0.6rem;
text-transform: none;
}
.fc .fc-toolbar-title {
font-size: 1.25rem;
font-weight: 600;
}
.fc .fc-daygrid-day-number,
.fc .fc-col-header-cell-cushion {
color: var(--foreground);
text-decoration: none;
}
.fc .fc-event {
border-radius: 6px;
padding: 1px 3px;
}
+5
View File
@@ -10,6 +10,7 @@ import {
verificationTokens,
} from "@/modules/_core/schema";
import { eq } from "drizzle-orm";
import { ensureDefaultCalendarsForMembership } from "@/modules/calendar/server/defaults";
declare module "next-auth" {
interface Session {
@@ -59,6 +60,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
.insert(householdMembers)
.values({ householdId: household.id, userId: user.id, role })
.onConflictDoNothing();
await ensureDefaultCalendarsForMembership({
householdId: household.id,
userId: user.id,
});
}
}
return true;
+4 -1
View File
@@ -1,7 +1,10 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "@/modules/_core/schema";
import * as coreSchema from "@/modules/_core/schema";
import * as calendarSchema from "@/modules/calendar/schema";
const client = postgres(process.env["DATABASE_URL"]!);
const schema = { ...coreSchema, ...calendarSchema };
export const db = drizzle(client, { schema });
@@ -0,0 +1,515 @@
"use client";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid";
import type {
DateSelectArg,
EventClickArg,
EventDropArg,
} from "@fullcalendar/core";
import type { EventResizeDoneArg } from "@fullcalendar/interaction";
import { CalendarPlus, Check, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import { useMemo, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { CalendarDto, CalendarEventDto } from "../server/queries";
import {
createCalendar,
createEvent,
deleteCalendar,
deleteEvent,
renameCalendar,
setCalendarColor,
setCalendarVisibility,
updateEvent,
} from "../server/actions";
type EventDraft = {
id?: string;
calendarId: string;
title: string;
startAt: string;
endAt: string;
allDay: boolean;
location: string;
notes: string;
};
const DEFAULT_COLOR = "#2563eb";
export function CalendarShell({
calendars,
events,
}: {
calendars: CalendarDto[];
events: CalendarEventDto[];
}) {
const [calendarRows, setCalendarRows] = useState(calendars);
const [eventRows, setEventRows] = useState(events);
const [visibleIds, setVisibleIds] = useState(() => new Set(calendars.map((c) => c.id)));
const [selectedEvent, setSelectedEvent] = useState<EventDraft | null>(null);
const [calendarName, setCalendarName] = useState("");
const [calendarColor, setCalendarColorValue] = useState(DEFAULT_COLOR);
const [calendarVisibility, setCalendarVisibilityValue] = useState<"private" | "household">(
"household",
);
const [lastCalendarId, setLastCalendarId] = useState(calendars[0]?.id ?? "");
const [isPending, startTransition] = useTransition();
const defaultCalendarId = calendarRows.some((calendar) => calendar.id === lastCalendarId)
? lastCalendarId
: (calendarRows[0]?.id ?? "");
const visibleEvents = useMemo(
() => eventRows.filter((event) => visibleIds.has(event.calendarId)),
[eventRows, visibleIds],
);
function toggleCalendar(id: string) {
setVisibleIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function openNewEvent(startAt?: Date, endAt?: Date, allDay = false) {
if (!defaultCalendarId) return;
const start = startAt ?? new Date();
const end = endAt ?? new Date(start.getTime() + 60 * 60 * 1000);
setSelectedEvent({
calendarId: defaultCalendarId,
title: "",
startAt: toInputDateTime(start),
endAt: toInputDateTime(end),
allDay,
location: "",
notes: "",
});
}
function openExistingEvent({ event }: EventClickArg) {
const row = eventRows.find((item) => item.id === event.id);
if (!row) return;
setSelectedEvent({
id: row.id,
calendarId: row.calendarId,
title: row.title,
startAt: toInputDateTime(new Date(row.startAt)),
endAt: toInputDateTime(new Date(row.endAt)),
allDay: row.allDay,
location: row.location ?? "",
notes: row.notes ?? "",
});
}
function handleDateSelect(selection: DateSelectArg) {
openNewEvent(selection.start, selection.end, selection.allDay);
}
function saveSelectedEvent() {
if (!selectedEvent) return;
const eventId = selectedEvent.id;
const payload = {
calendarId: selectedEvent.calendarId,
title: selectedEvent.title,
startAt: new Date(selectedEvent.startAt),
endAt: new Date(selectedEvent.endAt),
allDay: selectedEvent.allDay,
location: selectedEvent.location || null,
notes: selectedEvent.notes || null,
};
startTransition(async () => {
if (eventId) {
await updateEvent({ id: eventId, ...payload });
setLastCalendarId(payload.calendarId);
setEventRows((current) =>
current.map((event) =>
event.id === eventId
? {
...event,
calendarId: payload.calendarId,
title: payload.title,
allDay: payload.allDay,
location: payload.location,
notes: payload.notes,
startAt: payload.startAt.toISOString(),
endAt: payload.endAt.toISOString(),
}
: event,
),
);
} else {
const created = await createEvent(payload);
setLastCalendarId(payload.calendarId);
setEventRows((current) => [...current, created]);
}
setSelectedEvent(null);
});
}
function removeSelectedEvent() {
if (!selectedEvent?.id) return;
const id = selectedEvent.id;
startTransition(async () => {
await deleteEvent({ id });
setEventRows((current) => current.filter((event) => event.id !== id));
setSelectedEvent(null);
});
}
function moveEvent(change: EventDropArg | EventResizeDoneArg) {
const start = change.event.start;
const end = change.event.end ?? start;
if (!start || !end) return;
const id = change.event.id;
setEventRows((current) =>
current.map((event) =>
event.id === id
? {
...event,
startAt: start.toISOString(),
endAt: end.toISOString(),
allDay: change.event.allDay,
}
: event,
),
);
startTransition(async () => {
await updateEvent({
id,
startAt: start,
endAt: end,
allDay: change.event.allDay,
});
});
}
function addCalendar() {
startTransition(async () => {
const created = await createCalendar({
name: calendarName,
color: calendarColor,
visibility: calendarVisibility,
});
const calendar = {
id: created.id,
name: created.name,
color: created.color,
visibility: created.visibility as "private" | "household",
ownerId: created.ownerId,
};
setCalendarRows((current) => [...current, calendar]);
setVisibleIds((current) => new Set([...current, calendar.id]));
setLastCalendarId(calendar.id);
setCalendarName("");
setCalendarColorValue(DEFAULT_COLOR);
setCalendarVisibilityValue("household");
});
}
function updateCalendar(calendar: CalendarDto, values: Partial<CalendarDto>) {
const next = { ...calendar, ...values };
setCalendarRows((current) =>
current.map((item) => (item.id === calendar.id ? next : item)),
);
startTransition(async () => {
if (values.name !== undefined) {
await renameCalendar({ id: calendar.id, name: values.name });
}
if (values.color !== undefined) {
await setCalendarColor({ id: calendar.id, color: values.color });
}
if (values.visibility !== undefined) {
await setCalendarVisibility({ id: calendar.id, visibility: values.visibility });
}
});
}
function updateCalendarName(calendar: CalendarDto, name: string) {
setCalendarRows((current) =>
current.map((item) => (item.id === calendar.id ? { ...item, name } : item)),
);
}
function commitCalendarName(calendar: CalendarDto) {
if (!calendar.name.trim()) return;
startTransition(async () => {
await renameCalendar({ id: calendar.id, name: calendar.name });
});
}
function removeCalendar(calendarId: string) {
startTransition(async () => {
await deleteCalendar({ id: calendarId });
setCalendarRows((current) => current.filter((calendar) => calendar.id !== calendarId));
setEventRows((current) => current.filter((event) => event.calendarId !== calendarId));
setVisibleIds((current) => {
const next = new Set(current);
next.delete(calendarId);
return next;
});
});
}
return (
<div className="grid min-h-[calc(100vh-57px)] grid-cols-1 lg:grid-cols-[280px_1fr]">
<aside className="border-b bg-sidebar p-4 lg:border-r lg:border-b-0">
<div className="mb-4 flex items-center justify-between">
<h1 className="text-xl font-semibold">Calendar</h1>
<Button size="icon-sm" variant="outline" onClick={() => openNewEvent()}>
<Plus />
<span className="sr-only">New event</span>
</Button>
</div>
<div className="space-y-2">
{calendarRows.map((calendar) => (
<div key={calendar.id} className="rounded-lg border bg-background p-2">
<div className="flex items-center gap-2">
<button
type="button"
className="grid size-7 place-items-center rounded-md border"
onClick={() => toggleCalendar(calendar.id)}
aria-label={`${visibleIds.has(calendar.id) ? "Hide" : "Show"} ${calendar.name}`}
>
{visibleIds.has(calendar.id) ? <Eye /> : <EyeOff />}
</button>
<span
className="size-3 rounded-full"
style={{ backgroundColor: calendar.color ?? DEFAULT_COLOR }}
/>
<Input
aria-label={`${calendar.name} name`}
value={calendar.name}
onChange={(event) => updateCalendarName(calendar, event.target.value)}
onBlur={() => commitCalendarName(calendar)}
/>
</div>
<div className="mt-2 grid grid-cols-[1fr_1fr_auto] gap-2">
<Input
aria-label={`${calendar.name} color`}
type="color"
value={calendar.color ?? DEFAULT_COLOR}
onChange={(event) => updateCalendar(calendar, { color: event.target.value })}
/>
<Select
value={calendar.visibility}
onValueChange={(value) =>
updateCalendar(calendar, {
visibility: value as "private" | "household",
})
}
>
<SelectTrigger aria-label={`${calendar.name} visibility`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
<Button
size="icon"
variant="ghost"
onClick={() => removeCalendar(calendar.id)}
aria-label={`Delete ${calendar.name}`}
>
<Trash2 />
</Button>
</div>
</div>
))}
</div>
<div className="mt-4 rounded-lg border bg-background p-3">
<div className="mb-3 flex items-center gap-2 text-sm font-medium">
<CalendarPlus className="size-4" />
New calendar
</div>
<div className="space-y-2">
<Label htmlFor="calendar-name">Name</Label>
<Input
id="calendar-name"
value={calendarName}
onChange={(event) => setCalendarName(event.target.value)}
/>
<div className="grid grid-cols-[1fr_1fr] gap-2">
<Input
aria-label="Calendar color"
type="color"
value={calendarColor}
onChange={(event) => setCalendarColorValue(event.target.value)}
/>
<Select
value={calendarVisibility}
onValueChange={(value) =>
setCalendarVisibilityValue(value as "private" | "household")
}
>
<SelectTrigger aria-label="Calendar visibility">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="household">Household</SelectItem>
<SelectItem value="private">Private</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full" onClick={addCalendar} disabled={!calendarName || isPending}>
<Check />
Create calendar
</Button>
</div>
</div>
</aside>
<section className="min-w-0 p-4">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
}}
selectable
editable
eventResizableFromStart
select={handleDateSelect}
eventClick={openExistingEvent}
eventDrop={moveEvent}
eventResize={moveEvent}
events={visibleEvents.map((event) => ({
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
allDay: event.allDay,
backgroundColor:
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
DEFAULT_COLOR,
borderColor:
calendarRows.find((calendar) => calendar.id === event.calendarId)?.color ??
DEFAULT_COLOR,
}))}
height="auto"
/>
</section>
{selectedEvent && (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/20 p-4">
<div className="w-full max-w-lg rounded-lg bg-popover p-4 text-popover-foreground shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">
{selectedEvent.id ? "Edit event" : "New event"}
</h2>
<Button size="icon-sm" variant="ghost" onClick={() => setSelectedEvent(null)}>
<span aria-hidden>×</span>
<span className="sr-only">Close</span>
</Button>
</div>
<div className="grid gap-3">
<Label htmlFor="event-title">Title</Label>
<Input
id="event-title"
value={selectedEvent.title}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, title: event.target.value })
}
/>
<Label htmlFor="event-calendar">Calendar</Label>
<Select
value={selectedEvent.calendarId}
onValueChange={(value) =>
setSelectedEvent({ ...selectedEvent, calendarId: value ?? "" })
}
>
<SelectTrigger id="event-calendar">
<SelectValue />
</SelectTrigger>
<SelectContent>
{calendarRows.map((calendar) => (
<SelectItem key={calendar.id} value={calendar.id}>
{calendar.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="event-start">Start</Label>
<Input
id="event-start"
type="datetime-local"
value={selectedEvent.startAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, startAt: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="event-end">End</Label>
<Input
id="event-end"
type="datetime-local"
value={selectedEvent.endAt}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, endAt: event.target.value })
}
/>
</div>
</div>
<Label htmlFor="event-location">Location</Label>
<Input
id="event-location"
value={selectedEvent.location}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, location: event.target.value })
}
/>
<Label htmlFor="event-notes">Notes</Label>
<textarea
id="event-notes"
className="min-h-20 rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
value={selectedEvent.notes}
onChange={(event) =>
setSelectedEvent({ ...selectedEvent, notes: event.target.value })
}
/>
<div className="flex items-center justify-between gap-2 pt-2">
<div>
{selectedEvent.id && (
<Button variant="destructive" onClick={removeSelectedEvent}>
Delete event
</Button>
)}
</div>
<Button onClick={saveSelectedEvent} disabled={!selectedEvent.title || isPending}>
Save event
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
function toInputDateTime(date: Date) {
const offset = date.getTimezoneOffset();
const local = new Date(date.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
-23
View File
@@ -1,23 +0,0 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "calendar",
name: "Calendar",
nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
entities: [
{
type: "calendar.calendar",
label: { singular: "Calendar", plural: "Calendars" },
resolveUrl: (id) => `/calendar?id=${id}`,
},
{
type: "calendar.event",
label: { singular: "Event", plural: "Events" },
resolveUrl: (id) => `/calendar/events/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;
+87
View File
@@ -0,0 +1,87 @@
import type { ModuleManifest } from "../_core/module";
import { z } from "zod";
import { listCalendars, searchCalendars, searchEvents } from "./server/queries";
const calendarIdsSchema = z.union([z.literal("all"), z.array(z.string().uuid())]);
const manifest: ModuleManifest = {
id: "calendar",
name: "Calendar",
nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
entities: [
{
type: "calendar.calendar",
label: { singular: "Calendar", plural: "Calendars" },
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchCalendars },
resolveUrl: (id) => `/calendar?id=${id}`,
},
{
type: "calendar.event",
label: { singular: "Event", plural: "Events" },
share: { canShare: true, defaultCapabilities: ["read"] },
reminder: { canRemind: true },
search: { search: searchEvents },
resolveUrl: (id) => `/calendar/events/${id}`,
},
],
dashboardWidgets: [
{
id: "calendar.upcoming",
title: "Upcoming events",
description: "Events from selected calendars over the next few days.",
category: "Calendar",
defaultSize: { w: 4, h: 3 },
minSize: { w: 3, h: 2 },
defaultPriority: 10,
configSchema: z.object({
calendarIds: calendarIdsSchema,
days: z.number().int().min(1).max(30),
}),
defaultConfig: { calendarIds: "all", days: 3 },
resolveConfigOptions: async () => ({
calendars: (await listCalendars()).map((calendar) => ({
id: calendar.id,
name: calendar.name,
visibility: calendar.visibility,
})),
}),
render: () => <div className="text-sm text-muted-foreground">Upcoming events</div>,
},
{
id: "calendar.month",
title: "Month calendar",
description: "A compact month view for selected calendars.",
category: "Calendar",
defaultSize: { w: 6, h: 5 },
minSize: { w: 4, h: 4 },
defaultPriority: 20,
configSchema: z.object({ calendarIds: calendarIdsSchema }),
defaultConfig: { calendarIds: "all" },
resolveConfigOptions: async () => ({
calendars: (await listCalendars()).map((calendar) => ({
id: calendar.id,
name: calendar.name,
visibility: calendar.visibility,
})),
}),
render: () => <div className="text-sm text-muted-foreground">Month calendar</div>,
},
],
quickAdds: [
{
id: "calendar.new-event",
label: "New event",
icon: "calendar-plus",
action: () => undefined,
},
{
id: "calendar.new-calendar",
label: "New calendar",
icon: "calendar-days",
action: () => undefined,
},
],
};
export default manifest;
+65
View File
@@ -0,0 +1,65 @@
import { sql } from "drizzle-orm";
import {
boolean,
check,
index,
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
import { households, users } from "../_core/schema";
export const calendars = pgTable(
"calendars",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
color: text("color"),
visibility: text("visibility").notNull().default("household"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check("calendars_visibility_check", sql`${t.visibility} in ('private', 'household')`),
index("calendars_household_idx").on(t.householdId),
index("calendars_owner_idx").on(t.ownerId),
],
);
export const calendarEvents = pgTable(
"calendar_events",
{
id: uuid("id").primaryKey().defaultRandom(),
calendarId: uuid("calendar_id")
.notNull()
.references(() => calendars.id, { onDelete: "cascade" }),
title: text("title").notNull(),
startAt: timestamp("start_at", { withTimezone: true }).notNull(),
endAt: timestamp("end_at", { withTimezone: true }).notNull(),
allDay: boolean("all_day").notNull().default(false),
location: text("location"),
notes: text("notes"),
ownerId: uuid("owner_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
rrule: text("rrule"),
externalSource: text("external_source"),
externalId: text("external_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("calendar_events_calendar_start_idx").on(t.calendarId, t.startAt),
check("calendar_events_range_check", sql`${t.endAt} >= ${t.startAt}`),
],
);
export type Calendar = typeof calendars.$inferSelect;
export type CalendarEvent = typeof calendarEvents.$inferSelect;
+182
View File
@@ -0,0 +1,182 @@
"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { calendarEvents, calendars } from "../schema";
import { canSeeCalendar } from "./queries";
const calendarInput = z.object({
name: z.string().trim().min(1).max(120),
color: z.string().trim().min(1).max(32).nullable().optional(),
visibility: z.enum(["private", "household"]).default("household"),
});
const eventInput = z
.object({
calendarId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
allDay: z.boolean().default(false),
location: z.string().trim().max(300).nullable().optional(),
notes: z.string().trim().max(3000).nullable().optional(),
})
.refine((value) => value.endAt >= value.startAt, {
path: ["endAt"],
message: "End must be after start",
});
export async function createCalendar(input: z.input<typeof calendarInput>) {
const parsed = calendarInput.parse(input);
const { user, household } = await getCurrentSession();
const [calendar] = await db
.insert(calendars)
.values({
householdId: household.id,
ownerId: user.id,
name: parsed.name,
color: parsed.color ?? null,
visibility: parsed.visibility,
})
.returning();
if (!calendar) throw new Error("Calendar was not created");
revalidatePath("/calendar");
return calendar;
}
export async function renameCalendar(input: { id: string; name: string }) {
const { user } = await getCurrentSession();
const parsed = z.object({ id: z.string().uuid(), name: calendarInput.shape.name }).parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db
.update(calendars)
.set({ name: parsed.name, updatedAt: new Date() })
.where(eq(calendars.id, parsed.id));
revalidatePath("/calendar");
}
export async function setCalendarVisibility(input: {
id: string;
visibility: "private" | "household";
}) {
const { user } = await getCurrentSession();
const parsed = z
.object({ id: z.string().uuid(), visibility: calendarInput.shape.visibility })
.parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db
.update(calendars)
.set({ visibility: parsed.visibility, updatedAt: new Date() })
.where(eq(calendars.id, parsed.id));
revalidatePath("/calendar");
}
export async function setCalendarColor(input: { id: string; color: string | null }) {
const { user } = await getCurrentSession();
const parsed = z
.object({ id: z.string().uuid(), color: calendarInput.shape.color })
.parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db
.update(calendars)
.set({ color: parsed.color ?? null, updatedAt: new Date() })
.where(eq(calendars.id, parsed.id));
revalidatePath("/calendar");
}
export async function deleteCalendar(input: { id: string }) {
const { user } = await getCurrentSession();
const parsed = z.object({ id: z.string().uuid() }).parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await db.delete(calendars).where(eq(calendars.id, parsed.id));
revalidatePath("/calendar");
}
export async function createEvent(input: z.input<typeof eventInput>) {
const parsed = eventInput.parse(input);
const { user } = await getCurrentSession();
if (!(await canSeeCalendar(user.id, parsed.calendarId))) throw new Error("Forbidden");
const [event] = await db
.insert(calendarEvents)
.values({
...parsed,
ownerId: user.id,
location: parsed.location || null,
notes: parsed.notes || null,
})
.returning();
if (!event) throw new Error("Event was not created");
revalidatePath("/calendar");
return {
...event,
startAt: event.startAt.toISOString(),
endAt: event.endAt.toISOString(),
};
}
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
const parsed = z
.object({ id: z.string().uuid() })
.and(eventInput.partial())
.parse(input);
const { user } = await getCurrentSession();
const [existing] = await db
.select({ calendarId: calendarEvents.calendarId })
.from(calendarEvents)
.where(eq(calendarEvents.id, parsed.id))
.limit(1);
if (!existing) throw new Error("Event not found");
const calendarId = parsed.calendarId ?? existing.calendarId;
if (!(await canSeeCalendar(user.id, calendarId))) throw new Error("Forbidden");
await db
.update(calendarEvents)
.set({
calendarId,
title: parsed.title,
startAt: parsed.startAt,
endAt: parsed.endAt,
allDay: parsed.allDay,
location: parsed.location === undefined ? undefined : parsed.location || null,
notes: parsed.notes === undefined ? undefined : parsed.notes || null,
updatedAt: new Date(),
})
.where(eq(calendarEvents.id, parsed.id));
revalidatePath("/calendar");
}
export async function deleteEvent(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { user } = await getCurrentSession();
const [existing] = await db
.select({ calendarId: calendarEvents.calendarId })
.from(calendarEvents)
.where(eq(calendarEvents.id, parsed.id))
.limit(1);
if (!existing) return;
if (!(await canSeeCalendar(user.id, existing.calendarId))) throw new Error("Forbidden");
await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id));
revalidatePath("/calendar");
}
async function assertOwnsCalendar(userId: string, calendarId: string) {
const [calendar] = await db
.select({ id: calendars.id })
.from(calendars)
.where(and(eq(calendars.id, calendarId), eq(calendars.ownerId, userId)))
.limit(1);
if (!calendar) throw new Error("Forbidden");
}
+90
View File
@@ -0,0 +1,90 @@
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { householdMembers, households, users } from "@/modules/_core/schema";
import { calendars } from "../schema";
const HOME_COLOR = "#2563eb";
const PERSONAL_COLOR = "#16a34a";
export async function ensureDefaultCalendarsForMembership({
householdId,
userId,
}: {
householdId: string;
userId: string;
}) {
await ensureHomeCalendar(householdId);
await ensurePersonalCalendar({ householdId, userId });
}
export async function ensureDefaultCalendars() {
const householdRows = await db.select().from(households);
for (const household of householdRows) {
await ensureHomeCalendar(household.id);
}
const memberships = await db
.select({ householdId: householdMembers.householdId, userId: users.id })
.from(householdMembers)
.innerJoin(users, eq(householdMembers.userId, users.id));
for (const membership of memberships) {
await ensurePersonalCalendar(membership);
}
}
async function ensureHomeCalendar(householdId: string) {
const [existing] = await db
.select({ id: calendars.id })
.from(calendars)
.where(and(eq(calendars.householdId, householdId), eq(calendars.name, "Home")))
.limit(1);
if (existing) return;
const [owner] = await db
.select({ userId: householdMembers.userId })
.from(householdMembers)
.where(eq(householdMembers.householdId, householdId))
.limit(1);
if (!owner) return;
await db.insert(calendars).values({
householdId,
ownerId: owner.userId,
name: "Home",
color: HOME_COLOR,
visibility: "household",
});
}
async function ensurePersonalCalendar({
householdId,
userId,
}: {
householdId: string;
userId: string;
}) {
const [existing] = await db
.select({ id: calendars.id })
.from(calendars)
.where(
and(
eq(calendars.householdId, householdId),
eq(calendars.ownerId, userId),
eq(calendars.name, "Personal"),
),
)
.limit(1);
if (existing) return;
await db.insert(calendars).values({
householdId,
ownerId: userId,
name: "Personal",
color: PERSONAL_COLOR,
visibility: "private",
});
}
+187
View File
@@ -0,0 +1,187 @@
"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 { householdMembers } from "@/modules/_core/schema";
import { calendarEvents, calendars } from "../schema";
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())]),
});
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 listCalendars(): Promise<CalendarDto[]> {
const { user, household } = await getCurrentSession();
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, household.id),
or(eq(calendars.visibility, "household"), eq(calendars.ownerId, user.id)),
),
)
.orderBy(asc(calendars.name));
return rows.map((row) => ({
...row,
visibility: row.visibility as "private" | "household",
}));
}
export async function listEvents(input: {
from: Date | string;
to: Date | string;
calendarIds: "all" | string[];
}): Promise<CalendarEventDto[]> {
const parsed = listEventsSchema.parse(input);
const visibleCalendars = await listCalendars();
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 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,
}));
}
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(),
};
}