diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..ea03a22 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,69 @@ +# famapp HTTP API (v1) + +REST JSON API under `/api/v1/`. Full OpenAPI spec is planned in task 87.3. + +## Authentication + +Every endpoint accepts **either**: + +- Auth.js session cookie (browser login), or +- `Authorization: Bearer ` header + +Unauthorized requests return `401` with `{ "error": "Unauthorized" }`. + +Generate bearer tokens in **Settings → API tokens**. + +## Endpoints (87.2) + +### Calendars + +| Method | Path | Description | +| ------ | ----------------------- | -------------------------------------------------------- | +| GET | `/api/v1/calendars` | List visible calendars | +| POST | `/api/v1/calendars` | Create calendar (`name`, optional `color`, `visibility`) | +| GET | `/api/v1/calendars/:id` | Get calendar | +| PATCH | `/api/v1/calendars/:id` | Update `name`, `visibility`, `color` | +| DELETE | `/api/v1/calendars/:id` | Delete calendar | + +### Events + +| Method | Path | Description | +| ------ | --------------------------------------- | --------------------------------------------------------------------- | +| GET | `/api/v1/events?from=&to=&calendarIds=` | List events in range (`calendarIds` = `all` or comma-separated UUIDs) | +| POST | `/api/v1/events` | Create event | +| GET | `/api/v1/events/:id` | Get event | +| PATCH | `/api/v1/events/:id` | Update event | +| DELETE | `/api/v1/events/:id` | Delete event | + +### Lists + +| Method | Path | Description | +| ------ | --------------------------------- | ------------------------------------------------------------- | +| GET | `/api/v1/lists?type=` | List lists (optional `type` filter, e.g. `shopping`, `tasks`) | +| POST | `/api/v1/lists` | Create list (`type`, `name`) | +| GET | `/api/v1/lists/:id` | Get list with items | +| PATCH | `/api/v1/lists/:id` | Update `name` or `archived` | +| DELETE | `/api/v1/lists/:id` | Archive list | +| GET | `/api/v1/lists/:id/items` | List items | +| POST | `/api/v1/lists/:id/items` | Add item | +| PATCH | `/api/v1/lists/:id/items/:itemId` | Update or toggle item (`done`) | +| DELETE | `/api/v1/lists/:id/items/:itemId` | Delete item | + +### Notes + +| Method | Path | Description | +| ------ | ------------------- | ----------- | +| GET | `/api/v1/notes` | List notes | +| POST | `/api/v1/notes` | Create note | +| GET | `/api/v1/notes/:id` | Get note | +| PATCH | `/api/v1/notes/:id` | Update note | +| DELETE | `/api/v1/notes/:id` | Delete note | + +## Bearer token visibility + +Bearer tokens see **household-visible calendars only** (private calendars are hidden). Mutations on calendars require ownership (session) or household visibility (bearer). Activity log records `actorId: null` for bearer mutations. + +## Planned (87.3) + +- Garden, bangs routes +- OpenAPI specification diff --git a/src/app/api/v1/calendars/[id]/route.ts b/src/app/api/v1/calendars/[id]/route.ts new file mode 100644 index 0000000..792f312 --- /dev/null +++ b/src/app/api/v1/calendars/[id]/route.ts @@ -0,0 +1,33 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteCalendarForScope, updateCalendarForScope } from "@/modules/calendar/server/actions"; +import { calendarUpdateInput } from "@/modules/calendar/server/schemas"; +import { getCalendarForScope } from "@/modules/calendar/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + const calendar = await getCalendarForScope(scope, id); + return apiJson(calendar); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = calendarUpdateInput.parse(body); + await updateCalendarForScope(scope, { id, ...parsed }); + const calendar = await getCalendarForScope(scope, id); + return apiJson(calendar); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteCalendarForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/calendars/route.ts b/src/app/api/v1/calendars/route.ts new file mode 100644 index 0000000..7ff9ab0 --- /dev/null +++ b/src/app/api/v1/calendars/route.ts @@ -0,0 +1,29 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { createCalendarForScope } from "@/modules/calendar/server/actions"; +import { calendarInput } from "@/modules/calendar/server/schemas"; +import { listCalendarsForScope } from "@/modules/calendar/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope) => { + const calendars = await listCalendarsForScope(scope); + return apiJson(calendars); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = calendarInput.parse(body); + const calendar = await createCalendarForScope(scope, parsed); + return apiJson( + { + id: calendar.id, + name: calendar.name, + color: calendar.color, + visibility: calendar.visibility as "private" | "household", + ownerId: calendar.ownerId, + }, + 201, + ); + }); +} diff --git a/src/app/api/v1/events/[id]/route.ts b/src/app/api/v1/events/[id]/route.ts new file mode 100644 index 0000000..ebf7994 --- /dev/null +++ b/src/app/api/v1/events/[id]/route.ts @@ -0,0 +1,33 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteEventForScope, updateEventForScope } from "@/modules/calendar/server/actions"; +import { eventUpdateInput } from "@/modules/calendar/server/schemas"; +import { getEventForScope } from "@/modules/calendar/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + const event = await getEventForScope(scope, id); + return apiJson(event); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = eventUpdateInput.parse(body); + await updateEventForScope(scope, { id, ...parsed }); + const event = await getEventForScope(scope, id); + return apiJson(event); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteEventForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts new file mode 100644 index 0000000..c3d1cb8 --- /dev/null +++ b/src/app/api/v1/events/route.ts @@ -0,0 +1,37 @@ +import { apiError, apiJson, withApiHandler } from "@/lib/api-handler"; +import { createEventForScope } from "@/modules/calendar/server/actions"; +import { eventInput } from "@/modules/calendar/server/schemas"; +import { listEventsForScope } from "@/modules/calendar/server/queries"; + +function parseCalendarIds(value: string | null): "all" | string[] { + if (!value || value === "all") return "all"; + return value + .split(",") + .map((id) => id.trim()) + .filter(Boolean); +} + +export async function GET(request: Request) { + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const from = url.searchParams.get("from"); + const to = url.searchParams.get("to"); + const calendarIds = parseCalendarIds(url.searchParams.get("calendarIds")); + + if (!from || !to) { + return apiError("from and to query parameters are required", 400); + } + + const events = await listEventsForScope(scope, { from, to, calendarIds }); + return apiJson(events); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = eventInput.parse(body); + const event = await createEventForScope(scope, parsed); + return apiJson(event, 201); + }); +} diff --git a/src/app/api/v1/lists/[id]/items/[itemId]/route.ts b/src/app/api/v1/lists/[id]/items/[itemId]/route.ts new file mode 100644 index 0000000..75c81a0 --- /dev/null +++ b/src/app/api/v1/lists/[id]/items/[itemId]/route.ts @@ -0,0 +1,32 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteItemForScope, updateItemForScope } from "@/modules/lists/server/actions"; +import { updateItemInput } from "@/modules/lists/server/schemas"; +import { z } from "zod"; + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string; itemId: string }> }, +) { + const { itemId } = await params; + return withApiHandler(request, async (scope, req) => { + z.string().uuid().parse(itemId); + const body: unknown = await req.json(); + const parsed = updateItemInput.omit({ id: true }).parse(body); + const list = await updateItemForScope(scope, { id: itemId, ...parsed }); + const item = list.items.find((entry) => entry.id === itemId); + if (!item) throw new Error("Item not found"); + return apiJson(item); + }); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; itemId: string }> }, +) { + const { itemId } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(itemId); + await deleteItemForScope(scope, { id: itemId }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/lists/[id]/items/route.ts b/src/app/api/v1/lists/[id]/items/route.ts new file mode 100644 index 0000000..e27403c --- /dev/null +++ b/src/app/api/v1/lists/[id]/items/route.ts @@ -0,0 +1,25 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { addItemForScope } from "@/modules/lists/server/actions"; +import { itemInput } from "@/modules/lists/server/schemas"; +import { listItemsForScope } from "@/modules/lists/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + const items = await listItemsForScope(scope.householdId, id); + return apiJson(items); + }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + z.string().uuid().parse(id); + const body: unknown = await req.json(); + const parsed = itemInput.omit({ listId: true }).parse(body); + const item = await addItemForScope(scope, { listId: id, ...parsed }); + return apiJson(item, 201); + }); +} diff --git a/src/app/api/v1/lists/[id]/route.ts b/src/app/api/v1/lists/[id]/route.ts new file mode 100644 index 0000000..a88576e --- /dev/null +++ b/src/app/api/v1/lists/[id]/route.ts @@ -0,0 +1,33 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteListForScope, updateListForScope } from "@/modules/lists/server/actions"; +import { listUpdateInput } from "@/modules/lists/server/schemas"; +import { getListForScope } from "@/modules/lists/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + const list = await getListForScope(scope.householdId, id); + return apiJson(list); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = listUpdateInput.parse(body); + await updateListForScope(scope, { id, ...parsed }); + const list = await getListForScope(scope.householdId, id); + return apiJson(list); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteListForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/lists/route.ts b/src/app/api/v1/lists/route.ts new file mode 100644 index 0000000..7197d47 --- /dev/null +++ b/src/app/api/v1/lists/route.ts @@ -0,0 +1,33 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { createListForScope } from "@/modules/lists/server/actions"; +import { listInput } from "@/modules/lists/server/schemas"; +import { listListsForScope } from "@/modules/lists/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const type = url.searchParams.get("type") ?? undefined; + const lists = await listListsForScope(scope.householdId, type ? { type } : undefined); + return apiJson(lists); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = listInput.parse(body); + const list = await createListForScope(scope, parsed); + return apiJson( + { + id: list.id, + type: list.type, + name: list.name, + archived: list.archived, + openCount: 0, + doneCount: 0, + createdAt: list.createdAt.toISOString(), + }, + 201, + ); + }); +} diff --git a/src/app/api/v1/notes/[id]/route.ts b/src/app/api/v1/notes/[id]/route.ts new file mode 100644 index 0000000..20f74a8 --- /dev/null +++ b/src/app/api/v1/notes/[id]/route.ts @@ -0,0 +1,32 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { deleteNoteForScope, updateNoteForScope } from "@/modules/notes/server/actions"; +import { updateNoteInput } from "@/modules/notes/server/schemas"; +import { getNoteForScope } from "@/modules/notes/server/queries"; +import { z } from "zod"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + const note = await getNoteForScope(scope.householdId, id); + return apiJson(note); + }); +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = updateNoteInput.omit({ id: true }).parse(body); + const note = await updateNoteForScope(scope, { id, ...parsed }); + return apiJson(note); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteNoteForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/notes/route.ts b/src/app/api/v1/notes/route.ts new file mode 100644 index 0000000..8e39093 --- /dev/null +++ b/src/app/api/v1/notes/route.ts @@ -0,0 +1,20 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { createNoteForScope } from "@/modules/notes/server/actions"; +import { noteInput } from "@/modules/notes/server/schemas"; +import { listNotesForScope } from "@/modules/notes/server/queries"; + +export async function GET(request: Request) { + return withApiHandler(request, async (scope) => { + const notes = await listNotesForScope(scope.householdId); + return apiJson(notes); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = noteInput.parse(body); + const note = await createNoteForScope(scope, parsed); + return apiJson(note, 201); + }); +} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts index 34dc85b..c9c82ee 100644 --- a/src/lib/api-auth.ts +++ b/src/lib/api-auth.ts @@ -18,7 +18,12 @@ function parseBearerToken(request: Request): string | null { } async function resolveSessionAuth(): Promise { - const session = await auth(); + let session; + try { + session = await auth(); + } catch { + return null; + } if (!session?.user?.id) return null; const [row] = await db diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts new file mode 100644 index 0000000..931bbab --- /dev/null +++ b/src/lib/api-handler.ts @@ -0,0 +1,49 @@ +import { ZodError } from "zod"; +import { requireApiAuth, type ApiAuthContext } from "@/lib/api-auth"; + +export function apiJson(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export function apiError(message: string, status: number): Response { + return apiJson({ error: message }, status); +} + +const NOT_FOUND_PATTERN = /not found/i; + +function mapApiError(err: unknown): Response { + if (err instanceof ZodError) { + const message = err.issues[0]?.message ?? "Validation error"; + return apiError(message, 400); + } + + if (err instanceof Error) { + if (NOT_FOUND_PATTERN.test(err.message)) { + return apiError(err.message, 404); + } + if (err.message === "Forbidden") { + return apiError(err.message, 403); + } + return apiError(err.message, 500); + } + + return apiError("Internal server error", 500); +} + +export { mapApiError }; + +export async function withApiHandler( + request: Request, + handler: (scope: ApiAuthContext, request: Request) => Promise, +): Promise { + try { + const scope = await requireApiAuth(request); + return await handler(scope, request); + } catch (err) { + if (err instanceof Response) return err; + return mapApiError(err); + } +} diff --git a/src/modules/_core/activity.ts b/src/modules/_core/activity.ts index 730625b..350eb25 100644 --- a/src/modules/_core/activity.ts +++ b/src/modules/_core/activity.ts @@ -35,3 +35,22 @@ export async function logShareActivity(input: { payload: input.payload ?? null, }); } + +export async function logActivityForScope( + scope: { householdId: string; userId: string | null }, + input: { + entityType: string; + entityId: string; + action: string; + payload?: Record; + }, +): Promise { + await db.insert(activityLog).values({ + householdId: scope.householdId, + entityType: input.entityType, + entityId: input.entityId, + actorId: scope.userId, + action: input.action, + payload: input.payload ?? null, + }); +} diff --git a/src/modules/calendar/server/actions.ts b/src/modules/calendar/server/actions.ts index 21db0aa..74b5a62 100644 --- a/src/modules/calendar/server/actions.ts +++ b/src/modules/calendar/server/actions.ts @@ -5,52 +5,60 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; -import { logActivity } from "@/modules/_core/activity"; +import { logActivityForScope } from "@/modules/_core/activity"; +import { householdMembers } from "@/modules/_core/schema"; import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; import { calendarEvents, calendars } from "../schema"; -import { canSeeCalendar } from "./queries"; +import { canSeeCalendarForScope, type ApiScope, type CalendarEventDto } from "./queries"; +import { calendarInput, calendarUpdateInput, eventInput, eventUpdateInput } from "./schemas"; -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"), -}); +async function resolveOwnerId(scope: ApiScope): Promise { + if (scope.userId) return scope.userId; -const eventBaseInput = 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(), - remindMinutesBefore: z.number().int().min(0).nullable().optional(), -}); + const [member] = await db + .select({ userId: householdMembers.userId }) + .from(householdMembers) + .where( + and(eq(householdMembers.householdId, scope.householdId), eq(householdMembers.role, "owner")), + ) + .limit(1); -const eventInput = eventBaseInput.refine((value) => value.endAt >= value.startAt, { - path: ["endAt"], - message: "End must be after start", -}); + if (!member) throw new Error("No household owner found"); + return member.userId; +} -const eventUpdateInput = eventBaseInput.partial().refine( - (value) => { - if (!value.startAt || !value.endAt) return true; - return value.endAt >= value.startAt; - }, - { - path: ["endAt"], - message: "End must be after start", - }, -); +async function assertCanModifyCalendar(scope: ApiScope, calendarId: string) { + if (scope.userId) { + await assertOwnsCalendar(scope.userId, calendarId); + return; + } -export async function createCalendar(input: z.input) { + const [calendar] = await db + .select({ visibility: calendars.visibility, householdId: calendars.householdId }) + .from(calendars) + .where(eq(calendars.id, calendarId)) + .limit(1); + + if (!calendar || calendar.householdId !== scope.householdId) + throw new Error("Calendar not found"); + if (calendar.visibility !== "household") throw new Error("Forbidden"); +} + +export async function createCalendarForScope( + scope: ApiScope, + input: z.input, +) { const parsed = calendarInput.parse(input); - const { user, household } = await getCurrentSession(); + if (!scope.userId && parsed.visibility === "private") { + throw new Error("Bearer tokens cannot create private calendars"); + } + + const ownerId = await resolveOwnerId(scope); const [calendar] = await db .insert(calendars) .values({ - householdId: household.id, - ownerId: user.id, + householdId: scope.householdId, + ownerId, name: parsed.name, color: parsed.color ?? null, visibility: parsed.visibility, @@ -58,31 +66,64 @@ export async function createCalendar(input: z.input) { .returning(); if (!calendar) throw new Error("Calendar was not created"); - await logActivity({ + await logActivityForScope(scope, { entityType: "calendar.calendar", entityId: calendar.id, action: "create", payload: { name: calendar.name }, }); + return calendar; +} + +export async function createCalendar(input: z.input) { + const { user, household } = await getCurrentSession(); + const calendar = await createCalendarForScope( + { householdId: household.id, userId: user.id }, + input, + ); 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); +export async function updateCalendarForScope( + scope: ApiScope, + input: { id: string } & z.input, +) { + const parsed = z.object({ id: z.string().uuid() }).and(calendarUpdateInput).parse(input); + await assertCanModifyCalendar(scope, parsed.id); + + if (!scope.userId && parsed.visibility === "private") { + throw new Error("Bearer tokens cannot set private visibility"); + } + await db .update(calendars) - .set({ name: parsed.name, updatedAt: new Date() }) + .set({ + name: parsed.name, + visibility: parsed.visibility, + color: parsed.color === undefined ? undefined : (parsed.color ?? null), + updatedAt: new Date(), + }) .where(eq(calendars.id, parsed.id)); - await logActivity({ + await logActivityForScope(scope, { entityType: "calendar.calendar", entityId: parsed.id, action: "update", - payload: { name: parsed.name }, + payload: { + ...(parsed.name ? { name: parsed.name } : {}), + ...(parsed.visibility ? { visibility: parsed.visibility } : {}), + }, }); +} + +export async function renameCalendar(input: { id: string; name: string }) { + const { user, household } = await getCurrentSession(); + const parsed = z.object({ id: z.string().uuid(), name: calendarInput.shape.name }).parse(input); + await updateCalendarForScope( + { householdId: household.id, userId: user.id }, + { id: parsed.id, name: parsed.name }, + ); revalidatePath("/calendar"); } @@ -90,51 +131,52 @@ export async function setCalendarVisibility(input: { id: string; visibility: "private" | "household"; }) { - const { user } = await getCurrentSession(); + const { user, household } = 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)); - - await logActivity({ - entityType: "calendar.calendar", - entityId: parsed.id, - action: "update", - payload: { visibility: parsed.visibility }, - }); + await updateCalendarForScope( + { householdId: household.id, userId: user.id }, + { id: parsed.id, visibility: parsed.visibility }, + ); revalidatePath("/calendar"); } export async function setCalendarColor(input: { id: string; color: string | null }) { - const { user } = await getCurrentSession(); + const { user, household } = 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)); - + await updateCalendarForScope( + { householdId: household.id, userId: user.id }, + { id: parsed.id, color: parsed.color }, + ); revalidatePath("/calendar"); } +export async function deleteCalendarForScope(scope: ApiScope, input: { id: string }) { + const parsed = z.object({ id: z.string().uuid() }).parse(input); + await assertCanModifyCalendar(scope, parsed.id); + await logActivityForScope(scope, { + entityType: "calendar.calendar", + entityId: parsed.id, + action: "delete", + }); + await db.delete(calendars).where(eq(calendars.id, parsed.id)); +} + 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 logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "delete" }); - await db.delete(calendars).where(eq(calendars.id, parsed.id)); + const { user, household } = await getCurrentSession(); + await deleteCalendarForScope({ householdId: household.id, userId: user.id }, input); revalidatePath("/calendar"); } -export async function createEvent(input: z.input) { +export async function createEventForScope( + scope: ApiScope, + input: z.input, +): Promise { const parsed = eventInput.parse(input); - const { user, household } = await getCurrentSession(); - if (!(await canSeeCalendar(user.id, parsed.calendarId))) throw new Error("Forbidden"); + if (!(await canSeeCalendarForScope(scope, parsed.calendarId))) throw new Error("Forbidden"); + const ownerId = await resolveOwnerId(scope); const [event] = await db .insert(calendarEvents) .values({ @@ -143,7 +185,7 @@ export async function createEvent(input: z.input) { startAt: parsed.startAt, endAt: parsed.endAt, allDay: parsed.allDay, - ownerId: user.id, + ownerId, location: parsed.location || null, notes: parsed.notes || null, }) @@ -151,26 +193,26 @@ export async function createEvent(input: z.input) { if (!event) throw new Error("Event was not created"); - if (parsed.remindMinutesBefore != null) { + if (parsed.remindMinutesBefore != null && scope.userId) { const fireAt = new Date(parsed.startAt.getTime() - parsed.remindMinutesBefore * 60_000); if (fireAt > new Date()) { await scheduleReminder({ - householdId: household.id, + householdId: scope.householdId, entityType: "calendar.event", entityId: event.id, fireAt, - createdBy: user.id, + createdBy: scope.userId, }); } } - await logActivity({ + await logActivityForScope(scope, { entityType: "calendar.event", entityId: event.id, action: "create", payload: { title: event.title }, }); - revalidatePath("/calendar"); + return { ...event, startAt: event.startAt.toISOString(), @@ -178,9 +220,18 @@ export async function createEvent(input: z.input) { }; } -export async function updateEvent(input: { id: string } & Partial>) { +export async function createEvent(input: z.input) { + const { user, household } = await getCurrentSession(); + const event = await createEventForScope({ householdId: household.id, userId: user.id }, input); + revalidatePath("/calendar"); + return event; +} + +export async function updateEventForScope( + scope: ApiScope, + input: { id: string } & Partial>, +) { const parsed = z.object({ id: z.string().uuid() }).and(eventUpdateInput).parse(input); - const { user } = await getCurrentSession(); const [existing] = await db .select({ calendarId: calendarEvents.calendarId }) .from(calendarEvents) @@ -189,7 +240,7 @@ export async function updateEvent(input: { id: string } & Partial>) { + const { user, household } = await getCurrentSession(); + await updateEventForScope({ householdId: household.id, userId: user.id }, input); revalidatePath("/calendar"); } -export async function deleteEvent(input: { id: string }) { +export async function deleteEventForScope(scope: ApiScope, 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 logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "delete" }); + if (!existing) throw new Error("Event not found"); + if (!(await canSeeCalendarForScope(scope, existing.calendarId))) throw new Error("Forbidden"); + await logActivityForScope(scope, { + entityType: "calendar.event", + entityId: parsed.id, + action: "delete", + }); await cancelReminder("calendar.event", parsed.id); await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id)); +} + +export async function deleteEvent(input: { id: string }) { + const { user, household } = await getCurrentSession(); + await deleteEventForScope({ householdId: household.id, userId: user.id }, input); revalidatePath("/calendar"); } diff --git a/src/modules/calendar/server/queries.ts b/src/modules/calendar/server/queries.ts index 99e2691..0fd4a1c 100644 --- a/src/modules/calendar/server/queries.ts +++ b/src/modules/calendar/server/queries.ts @@ -7,6 +7,11 @@ import { getCurrentSession } from "@/lib/session"; 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; @@ -32,6 +37,30 @@ const listEventsSchema = z.object({ 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({ @@ -56,8 +85,7 @@ export async function canSeeCalendar(userId: string, calendarId: string) { return row.memberUserId === userId; } -export async function listCalendars(): Promise { - const { user, household } = await getCurrentSession(); +export async function listCalendarsForScope(scope: ApiScope): Promise { const rows = await db .select({ id: calendars.id, @@ -67,12 +95,7 @@ export async function listCalendars(): Promise { ownerId: calendars.ownerId, }) .from(calendars) - .where( - and( - eq(calendars.householdId, household.id), - or(eq(calendars.visibility, "household"), eq(calendars.ownerId, user.id)), - ), - ) + .where(and(eq(calendars.householdId, scope.householdId), calendarVisibilityFilter(scope))) .orderBy(asc(calendars.name)); return rows.map((row) => ({ @@ -81,13 +104,48 @@ export async function listCalendars(): Promise { })); } -export async function listEvents(input: { - from: Date | string; - to: Date | string; - calendarIds: "all" | string[]; -}): Promise { +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 listCalendars(); + const visibleCalendars = await listCalendarsForScope(scope); const visibleIds = new Set(visibleCalendars.map((calendar) => calendar.id)); const calendarIds = parsed.calendarIds === "all" @@ -120,6 +178,40 @@ export async function listEvents(input: { 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 }) diff --git a/src/modules/calendar/server/schemas.ts b/src/modules/calendar/server/schemas.ts new file mode 100644 index 0000000..f5ec018 --- /dev/null +++ b/src/modules/calendar/server/schemas.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export 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"), +}); + +export const calendarUpdateInput = z.object({ + name: calendarInput.shape.name.optional(), + visibility: calendarInput.shape.visibility.optional(), + color: calendarInput.shape.color, +}); + +export const eventBaseInput = 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(), + remindMinutesBefore: z.number().int().min(0).nullable().optional(), +}); + +export const eventInput = eventBaseInput.refine((value) => value.endAt >= value.startAt, { + path: ["endAt"], + message: "End must be after start", +}); + +export const eventUpdateInput = eventBaseInput.partial().refine( + (value) => { + if (!value.startAt || !value.endAt) return true; + return value.endAt >= value.startAt; + }, + { + path: ["endAt"], + message: "End must be after start", + }, +); diff --git a/src/modules/lists/server/actions.ts b/src/modules/lists/server/actions.ts index a3c9a2a..f2e0d30 100644 --- a/src/modules/lists/server/actions.ts +++ b/src/modules/lists/server/actions.ts @@ -3,92 +3,114 @@ import { and, eq, max } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; -import { logActivity } from "@/modules/_core/activity"; +import { logActivityForScope } from "@/modules/_core/activity"; import { fireItemToggleHooks } from "@/modules/_core/registry"; import { listItems, lists } from "../schema"; -import { canAccessList, getList } from "./queries"; +import { canAccessList, getList, getListForScope } from "./queries"; import { notifyListChanged } from "./realtime"; +import { itemInput, listInput, listUpdateInput, updateItemInput } from "./schemas"; -const listInput = z.object({ - type: z.string().trim().min(1).max(80), - name: z.string().trim().min(1).max(120), -}); +function toScope(ctx: ApiAuthContext) { + return { householdId: ctx.householdId, userId: ctx.userId }; +} -const itemInput = z.object({ - listId: z.string().uuid(), - text: z.string().trim().min(1).max(300), - qty: z.string().trim().max(80).nullable().optional(), - notes: z.string().trim().max(2000).nullable().optional(), - dueAt: z.coerce.date().nullable().optional(), - assigneeId: z.string().uuid().nullable().optional(), - metadata: z.record(z.string(), z.unknown()).nullable().optional(), -}); - -const updateItemInput = z.object({ - id: z.string().uuid(), - text: z.string().trim().min(1).max(300).optional(), - qty: z.string().trim().max(80).nullable().optional(), - notes: z.string().trim().max(2000).nullable().optional(), - dueAt: z.coerce.date().nullable().optional(), - assigneeId: z.string().uuid().nullable().optional(), -}); - -export async function createList(input: z.input) { +export async function createListForScope(scope: ApiAuthContext, input: z.input) { const parsed = listInput.parse(input); - const { household } = await getCurrentSession(); const [list] = await db .insert(lists) .values({ - householdId: household.id, + householdId: scope.householdId, type: parsed.type, name: parsed.name, }) .returning(); if (!list) throw new Error("List was not created"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "lists.list", entityId: list.id, action: "create", payload: { name: list.name }, }); + return list; +} + +export async function createList(input: z.input) { + const { household, user } = await getCurrentSession(); + const list = await createListForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); revalidatePath("/lists"); return list; } +export async function updateListForScope( + scope: ApiAuthContext, + input: { id: string } & z.input, +) { + const parsed = z.object({ id: z.string().uuid() }).and(listUpdateInput).parse(input); + await assertCanAccessList(parsed.id, scope.householdId); + + await db + .update(lists) + .set({ + name: parsed.name, + archived: parsed.archived, + }) + .where(eq(lists.id, parsed.id)); + + if (parsed.name) { + await logActivityForScope(toScope(scope), { + entityType: "lists.list", + entityId: parsed.id, + action: "update", + payload: { name: parsed.name }, + }); + } + if (parsed.archived === true) { + await logActivityForScope(toScope(scope), { + entityType: "lists.list", + entityId: parsed.id, + action: "archive", + }); + } + + await notifyListChanged(parsed.id); +} + export async function renameList(input: { id: string; name: string }) { const parsed = z.object({ id: z.string().uuid(), name: listInput.shape.name }).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessList(parsed.id, household.id); - await db.update(lists).set({ name: parsed.name }).where(eq(lists.id, parsed.id)); - await logActivity({ - entityType: "lists.list", - entityId: parsed.id, - action: "update", - payload: { name: parsed.name }, - }); + const { household, user } = await getCurrentSession(); + await updateListForScope( + { householdId: household.id, userId: user.id, role: null }, + { id: parsed.id, name: parsed.name }, + ); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); - await notifyListChanged(parsed.id); } export async function archiveList(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessList(parsed.id, household.id); - await db.update(lists).set({ archived: true }).where(eq(lists.id, parsed.id)); - await logActivity({ entityType: "lists.list", entityId: parsed.id, action: "archive" }); + const { household, user } = await getCurrentSession(); + await updateListForScope( + { householdId: household.id, userId: user.id, role: null }, + { id: parsed.id, archived: true }, + ); revalidatePath("/lists"); revalidatePath(`/lists/${parsed.id}`); - await notifyListChanged(parsed.id); } -export async function addItem(input: z.input) { +export async function deleteListForScope(scope: ApiAuthContext, input: { id: string }) { + await updateListForScope(scope, { id: input.id, archived: true }); +} + +export async function addItemForScope(scope: ApiAuthContext, input: z.input) { const parsed = itemInput.parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessList(parsed.listId, household.id); + await assertCanAccessList(parsed.listId, scope.householdId); const [positionRow] = await db .select({ maxPosition: max(listItems.position) }) @@ -110,21 +132,30 @@ export async function addItem(input: z.input) { .returning(); if (!item) throw new Error("List item was not created"); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: item.id, action: "create", payload: { text: item.text }, }); - revalidatePath(`/lists/${parsed.listId}`); await notifyListChanged(parsed.listId); + return toItemDto(item); +} + +export async function addItem(input: z.input) { + const parsed = itemInput.parse(input); + const { household, user } = await getCurrentSession(); + await addItemForScope({ householdId: household.id, userId: user.id, role: null }, parsed); + revalidatePath(`/lists/${parsed.listId}`); return getList(parsed.listId); } -export async function toggleItem(input: { id: string; done?: boolean }) { +export async function toggleItemForScope( + scope: ApiAuthContext, + input: { id: string; done?: boolean }, +) { const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input); - const { household, user } = await getCurrentSession(); - const existing = await getAuthorizedItem(parsed.id, household.id); + const existing = await getAuthorizedItem(parsed.id, scope.householdId); const done = parsed.done ?? !existing.done; await db @@ -132,31 +163,46 @@ export async function toggleItem(input: { id: string; done?: boolean }) { .set({ done, updatedAt: new Date() }) .where(eq(listItems.id, parsed.id)); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "toggle", payload: { done, text: existing.text }, }); - if (done && existing.metadata) { + if (done && existing.metadata && scope.userId) { await fireItemToggleHooks({ itemId: parsed.id, metadata: existing.metadata as Record, done, - userId: user.id, + userId: scope.userId, }); } - revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); - return getList(existing.listId); + return getListForScope(scope.householdId, existing.listId); } -export async function updateItem(input: z.input) { +export async function toggleItem(input: { id: string; done?: boolean }) { + const { household, user } = await getCurrentSession(); + const list = await toggleItemForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); + revalidatePath(`/lists/${list.id}`); + return list; +} + +export async function updateItemForScope( + scope: ApiAuthContext, + input: z.input, +) { const parsed = updateItemInput.parse(input); - const { household } = await getCurrentSession(); - const existing = await getAuthorizedItem(parsed.id, household.id); + const existing = await getAuthorizedItem(parsed.id, scope.householdId); + + if (parsed.done !== undefined) { + return toggleItemForScope(scope, { id: parsed.id, done: parsed.done }); + } await db .update(listItems) @@ -170,31 +216,48 @@ export async function updateItem(input: z.input) { }) .where(eq(listItems.id, parsed.id)); - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "update", payload: { text: parsed.text ?? existing.text }, }); - revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); - return getList(existing.listId); + return getListForScope(scope.householdId, existing.listId); } -export async function deleteItem(input: { id: string }) { +export async function updateItem(input: z.input) { + const { household, user } = await getCurrentSession(); + const list = await updateItemForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); + revalidatePath(`/lists/${list.id}`); + return list; +} + +export async function deleteItemForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); - const { household } = await getCurrentSession(); - const existing = await getAuthorizedItem(parsed.id, household.id); - await logActivity({ + const existing = await getAuthorizedItem(parsed.id, scope.householdId); + await logActivityForScope(toScope(scope), { entityType: "lists.item", entityId: parsed.id, action: "delete", payload: { text: existing.text }, }); await db.delete(listItems).where(eq(listItems.id, parsed.id)); - revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); - return getList(existing.listId); + return getListForScope(scope.householdId, existing.listId); +} + +export async function deleteItem(input: { id: string }) { + const { household, user } = await getCurrentSession(); + const list = await deleteItemForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); + revalidatePath(`/lists/${list.id}`); + return list; } export async function reorderItems(input: { listId: string; itemIds: string[] }) { @@ -239,3 +302,19 @@ async function getAuthorizedItem(itemId: string, householdId: string) { if (!item) throw new Error("Item not found"); return item; } + +function toItemDto(item: typeof listItems.$inferSelect) { + return { + id: item.id, + listId: item.listId, + text: item.text, + done: item.done, + qty: item.qty, + notes: item.notes, + dueAt: item.dueAt?.toISOString() ?? null, + assigneeId: item.assigneeId, + position: item.position, + createdAt: item.createdAt.toISOString(), + updatedAt: item.updatedAt.toISOString(), + }; +} diff --git a/src/modules/lists/server/queries.ts b/src/modules/lists/server/queries.ts index 551854c..a118e69 100644 --- a/src/modules/lists/server/queries.ts +++ b/src/modules/lists/server/queries.ts @@ -42,12 +42,14 @@ const listListsInput = z }) .optional(); -export async function listLists(input?: z.input): Promise { +export async function listListsForScope( + householdId: string, + input?: z.input, +): Promise { const parsed = listListsInput.parse(input) ?? { includeArchived: false }; - const { household } = await getCurrentSession(); - await ensureDefaultListsForHousehold(household.id); + await ensureDefaultListsForHousehold(householdId); - const conditions = [eq(lists.householdId, household.id)]; + const conditions = [eq(lists.householdId, householdId)]; if (parsed.type) conditions.push(eq(lists.type, parsed.type)); if (!parsed.includeArchived) conditions.push(eq(lists.archived, false)); @@ -68,15 +70,19 @@ export async function listLists(input?: z.input): Promise return summarizeLists(rows); } -export async function getList(id: string): Promise { - const parsed = z.string().uuid().parse(id); +export async function listLists(input?: z.input): Promise { const { household } = await getCurrentSession(); - await ensureDefaultListsForHousehold(household.id); + return listListsForScope(household.id, input); +} + +export async function getListForScope(householdId: string, id: string): Promise { + const parsed = z.string().uuid().parse(id); + await ensureDefaultListsForHousehold(householdId); const [list] = await db .select() .from(lists) - .where(and(eq(lists.id, parsed), eq(lists.householdId, household.id))) + .where(and(eq(lists.id, parsed), eq(lists.householdId, householdId))) .limit(1); if (!list) throw new Error("List not found"); @@ -94,6 +100,19 @@ export async function getList(id: string): Promise { }; } +export async function getList(id: string): Promise { + const { household } = await getCurrentSession(); + return getListForScope(household.id, id); +} + +export async function listItemsForScope( + householdId: string, + listId: string, +): Promise { + const list = await getListForScope(householdId, listId); + return list.items; +} + export async function canAccessList(listId: string, householdId: string) { const [list] = await db .select({ id: lists.id }) diff --git a/src/modules/lists/server/schemas.ts b/src/modules/lists/server/schemas.ts new file mode 100644 index 0000000..4265379 --- /dev/null +++ b/src/modules/lists/server/schemas.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const listInput = z.object({ + type: z.string().trim().min(1).max(80), + name: z.string().trim().min(1).max(120), +}); + +export const listUpdateInput = z.object({ + name: listInput.shape.name.optional(), + archived: z.boolean().optional(), +}); + +export const itemInput = z.object({ + listId: z.string().uuid(), + text: z.string().trim().min(1).max(300), + qty: z.string().trim().max(80).nullable().optional(), + notes: z.string().trim().max(2000).nullable().optional(), + dueAt: z.coerce.date().nullable().optional(), + assigneeId: z.string().uuid().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), +}); + +export const updateItemInput = z.object({ + id: z.string().uuid(), + text: z.string().trim().min(1).max(300).optional(), + qty: z.string().trim().max(80).nullable().optional(), + notes: z.string().trim().max(2000).nullable().optional(), + dueAt: z.coerce.date().nullable().optional(), + assigneeId: z.string().uuid().nullable().optional(), + done: z.boolean().optional(), +}); diff --git a/src/modules/notes/components/note-editor.tsx b/src/modules/notes/components/note-editor.tsx index 468c4d5..87c73a2 100644 --- a/src/modules/notes/components/note-editor.tsx +++ b/src/modules/notes/components/note-editor.tsx @@ -31,12 +31,7 @@ export function NoteEditor({ note }: { note?: NoteDto }) { body, remindAt: remindAt ? new Date(remindAt) : null, }); - setCurrentNote({ - ...updated, - createdAt: updated.createdAt.toISOString(), - updatedAt: updated.updatedAt.toISOString(), - remindAt: updated.remindAt?.toISOString() ?? null, - }); + setCurrentNote(updated); return; } diff --git a/src/modules/notes/server/actions.ts b/src/modules/notes/server/actions.ts index 6e3d768..b8de27c 100644 --- a/src/modules/notes/server/actions.ts +++ b/src/modules/notes/server/actions.ts @@ -1,39 +1,49 @@ "use server"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; +import type { ApiAuthContext } from "@/lib/api-auth"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; -import { logActivity } from "@/modules/_core/activity"; +import { logActivityForScope } from "@/modules/_core/activity"; +import { householdMembers } from "@/modules/_core/schema"; import { scheduleReminder, cancelReminder } from "@/modules/_core/reminders"; import { notes } from "../schema"; -import { canAccessNote, getNote } from "./queries"; +import { canAccessNote, getNoteForScope, type NoteDto } from "./queries"; +import { noteInput, updateNoteInput } from "./schemas"; -const noteInput = z.object({ - title: z.string().trim().min(1).max(200), - body: z.string().max(20000).default(""), - pinned: z.boolean().default(false), - remindAt: z.coerce.date().nullable().optional(), -}); +function toScope(ctx: ApiAuthContext) { + return { householdId: ctx.householdId, userId: ctx.userId }; +} -const updateNoteInput = z.object({ - id: z.string().uuid(), - title: noteInput.shape.title.optional(), - body: z.string().max(20000).optional(), - pinned: z.boolean().optional(), - remindAt: z.coerce.date().nullable().optional(), -}); +async function resolveAuthorId(scope: ApiAuthContext): Promise { + if (scope.userId) return scope.userId; -export async function createNote(input: z.input) { + const [member] = await db + .select({ userId: householdMembers.userId }) + .from(householdMembers) + .where( + and(eq(householdMembers.householdId, scope.householdId), eq(householdMembers.role, "owner")), + ) + .limit(1); + + if (!member) throw new Error("No household owner found"); + return member.userId; +} + +export async function createNoteForScope( + scope: ApiAuthContext, + input: z.input, +): Promise { const parsed = noteInput.parse(input); - const { household, user } = await getCurrentSession(); + const authorId = await resolveAuthorId(scope); const [note] = await db .insert(notes) .values({ - householdId: household.id, - authorId: user.id, + householdId: scope.householdId, + authorId, title: parsed.title, body: parsed.body, pinned: parsed.pinned, @@ -43,30 +53,51 @@ export async function createNote(input: z.input) { if (!note) throw new Error("Note was not created"); - if (parsed.remindAt) { + if (parsed.remindAt && scope.userId) { await scheduleReminder({ - householdId: household.id, + householdId: scope.householdId, entityType: "notes.note", entityId: note.id, fireAt: parsed.remindAt, - createdBy: user.id, + createdBy: scope.userId, }); } - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "notes.note", entityId: note.id, action: "create", payload: { title: note.title }, }); + return { + id: note.id, + householdId: note.householdId, + authorId: note.authorId, + title: note.title, + body: note.body, + pinned: note.pinned, + remindAt: note.remindAt?.toISOString() ?? null, + createdAt: note.createdAt.toISOString(), + updatedAt: note.updatedAt.toISOString(), + }; +} + +export async function createNote(input: z.input) { + const { household, user } = await getCurrentSession(); + const note = await createNoteForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); revalidatePath("/notes"); return note; } -export async function updateNote(input: z.input) { +export async function updateNoteForScope( + scope: ApiAuthContext, + input: z.input, +): Promise { const parsed = updateNoteInput.parse(input); - const { household, user } = await getCurrentSession(); - await assertCanAccessNote(parsed.id, household.id); + await assertCanAccessNote(parsed.id, scope.householdId); const [note] = await db .update(notes) @@ -82,34 +113,43 @@ export async function updateNote(input: z.input) { if (!note) throw new Error("Note was not updated"); - if (parsed.remindAt !== undefined) { + if (parsed.remindAt !== undefined && scope.userId) { if (parsed.remindAt) { await scheduleReminder({ - householdId: household.id, + householdId: scope.householdId, entityType: "notes.note", entityId: note.id, fireAt: parsed.remindAt, - createdBy: user.id, + createdBy: scope.userId, }); } else { await cancelReminder("notes.note", note.id); } } - await logActivity({ + await logActivityForScope(toScope(scope), { entityType: "notes.note", entityId: note.id, action: "update", payload: { title: note.title }, }); + return getNoteForScope(scope.householdId, note.id); +} + +export async function updateNote(input: z.input) { + const { household, user } = await getCurrentSession(); + const note = await updateNoteForScope( + { householdId: household.id, userId: user.id, role: null }, + input, + ); revalidatePath("/notes"); - revalidatePath(`/notes/${parsed.id}`); + revalidatePath(`/notes/${input.id}`); return note; } export async function setNotePinned(input: { id: string; pinned: boolean }) { const parsed = z.object({ id: z.string().uuid(), pinned: z.boolean() }).parse(input); - const { household } = await getCurrentSession(); + const { household, user } = await getCurrentSession(); await assertCanAccessNote(parsed.id, household.id); await db @@ -117,34 +157,40 @@ export async function setNotePinned(input: { id: string; pinned: boolean }) { .set({ pinned: parsed.pinned, updatedAt: new Date() }) .where(eq(notes.id, parsed.id)); - const note = await getNote(parsed.id); - await logActivity({ - entityType: "notes.note", - entityId: parsed.id, - action: parsed.pinned ? "pin" : "unpin", - payload: note ? { title: note.title } : undefined, - }); + const note = await getNoteForScope(household.id, parsed.id); + await logActivityForScope( + { householdId: household.id, userId: user.id }, + { + entityType: "notes.note", + entityId: parsed.id, + action: parsed.pinned ? "pin" : "unpin", + payload: { title: note.title }, + }, + ); revalidatePath("/notes"); revalidatePath(`/notes/${parsed.id}`); return note; } -export async function deleteNote(input: { id: string }) { +export async function deleteNoteForScope(scope: ApiAuthContext, input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); - const { household } = await getCurrentSession(); - await assertCanAccessNote(parsed.id, household.id); + await assertCanAccessNote(parsed.id, scope.householdId); - const note = await getNote(parsed.id); - await logActivity({ + const note = await getNoteForScope(scope.householdId, parsed.id); + await logActivityForScope(toScope(scope), { entityType: "notes.note", entityId: parsed.id, action: "delete", - payload: note ? { title: note.title } : undefined, + payload: { title: note.title }, }); await cancelReminder("notes.note", parsed.id); await db.delete(notes).where(eq(notes.id, parsed.id)); +} +export async function deleteNote(input: { id: string }) { + const { household, user } = await getCurrentSession(); + await deleteNoteForScope({ householdId: household.id, userId: user.id, role: null }, input); revalidatePath("/notes"); } diff --git a/src/modules/notes/server/queries.ts b/src/modules/notes/server/queries.ts index 72bdfea..349af12 100644 --- a/src/modules/notes/server/queries.ts +++ b/src/modules/notes/server/queries.ts @@ -18,30 +18,38 @@ export type NoteDto = { updatedAt: string; }; -export async function listNotes(): Promise { - const { household } = await getCurrentSession(); +export async function listNotesForScope(householdId: string): Promise { const rows = await db .select() .from(notes) - .where(eq(notes.householdId, household.id)) + .where(eq(notes.householdId, householdId)) .orderBy(desc(notes.pinned), desc(notes.updatedAt)); return rows.map(toNoteDto); } -export async function getNote(id: string): Promise { - const parsed = z.string().uuid().parse(id); +export async function listNotes(): Promise { const { household } = await getCurrentSession(); + return listNotesForScope(household.id); +} + +export async function getNoteForScope(householdId: string, id: string): Promise { + const parsed = z.string().uuid().parse(id); const [note] = await db .select() .from(notes) - .where(and(eq(notes.id, parsed), eq(notes.householdId, household.id))) + .where(and(eq(notes.id, parsed), eq(notes.householdId, householdId))) .limit(1); if (!note) throw new Error("Note not found"); return toNoteDto(note); } +export async function getNote(id: string): Promise { + const { household } = await getCurrentSession(); + return getNoteForScope(household.id, id); +} + export async function canAccessNote(noteId: string, householdId: string) { const [note] = await db .select({ id: notes.id }) diff --git a/src/modules/notes/server/schemas.ts b/src/modules/notes/server/schemas.ts new file mode 100644 index 0000000..8890995 --- /dev/null +++ b/src/modules/notes/server/schemas.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const noteInput = z.object({ + title: z.string().trim().min(1).max(200), + body: z.string().max(20000).default(""), + pinned: z.boolean().default(false), + remindAt: z.coerce.date().nullable().optional(), +}); + +export const updateNoteInput = z.object({ + id: z.string().uuid(), + title: noteInput.shape.title.optional(), + body: z.string().max(20000).optional(), + pinned: z.boolean().optional(), + remindAt: z.coerce.date().nullable().optional(), +}); diff --git a/tests/unit/api-v1-calendar.test.ts b/tests/unit/api-v1-calendar.test.ts new file mode 100644 index 0000000..0adbdcf --- /dev/null +++ b/tests/unit/api-v1-calendar.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { z } from "zod"; +import { apiError, apiJson, mapApiError, withApiHandler } from "../../src/lib/api-handler"; + +describe("apiJson", () => { + it("returns JSON with default 200 status", async () => { + const response = apiJson({ ok: true }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("Content-Type"), "application/json"); + assert.deepEqual(await response.json(), { ok: true }); + }); + + it("accepts custom status codes", async () => { + const response = apiJson({ created: true }, 201); + assert.equal(response.status, 201); + }); +}); + +describe("apiError", () => { + it("returns error payload with given status", async () => { + const response = apiError("Unauthorized", 401); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +}); + +describe("mapApiError", () => { + it("maps Zod validation errors to 400", async () => { + const err = z.object({ name: z.string().min(1) }).safeParse({}).error; + assert.ok(err); + const response = mapApiError(err); + assert.equal(response.status, 400); + }); + + it("maps not-found errors to 404", async () => { + const response = mapApiError(new Error("Calendar not found")); + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "Calendar not found" }); + }); + + it("maps forbidden errors to 403", async () => { + const response = mapApiError(new Error("Forbidden")); + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "Forbidden" }); + }); +}); + +describe("withApiHandler", () => { + it("returns 401 when requireApiAuth throws a Response", async () => { + const request = new Request("http://localhost/api/v1/calendars"); + const response = await withApiHandler(request, async () => apiJson([])); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +}); + +describe("GET /api/v1/calendars auth gate", () => { + it("returns 401 without authentication", async () => { + const { GET } = await import("../../src/app/api/v1/calendars/route"); + const request = new Request("http://localhost/api/v1/calendars"); + const response = await GET(request); + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "Unauthorized" }); + }); +});