feat: api v1 routes for calendar lists and notes
This commit is contained in:
@@ -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 <household-api-token>` 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
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+6
-1
@@ -18,7 +18,12 @@ function parseBearerToken(request: Request): string | null {
|
||||
}
|
||||
|
||||
async function resolveSessionAuth(): Promise<ApiAuthContext | null> {
|
||||
const session = await auth();
|
||||
let session;
|
||||
try {
|
||||
session = await auth();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
const [row] = await db
|
||||
|
||||
@@ -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<Response>,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const scope = await requireApiAuth(request);
|
||||
return await handler(scope, request);
|
||||
} catch (err) {
|
||||
if (err instanceof Response) return err;
|
||||
return mapApiError(err);
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.insert(activityLog).values({
|
||||
householdId: scope.householdId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: scope.userId,
|
||||
action: input.action,
|
||||
payload: input.payload ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string> {
|
||||
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<typeof calendarInput>) {
|
||||
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<typeof calendarInput>,
|
||||
) {
|
||||
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<typeof calendarInput>) {
|
||||
.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<typeof calendarInput>) {
|
||||
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<typeof calendarUpdateInput>,
|
||||
) {
|
||||
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<typeof eventInput>) {
|
||||
export async function createEventForScope(
|
||||
scope: ApiScope,
|
||||
input: z.input<typeof eventInput>,
|
||||
): Promise<CalendarEventDto> {
|
||||
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<typeof eventInput>) {
|
||||
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<typeof eventInput>) {
|
||||
|
||||
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<typeof eventInput>) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
|
||||
export async function createEvent(input: z.input<typeof eventInput>) {
|
||||
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<z.input<typeof eventInput>>,
|
||||
) {
|
||||
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<z.input<typeof
|
||||
|
||||
if (!existing) throw new Error("Event not found");
|
||||
const calendarId = parsed.calendarId ?? existing.calendarId;
|
||||
if (!(await canSeeCalendar(user.id, calendarId))) throw new Error("Forbidden");
|
||||
if (!(await canSeeCalendarForScope(scope, calendarId))) throw new Error("Forbidden");
|
||||
|
||||
await db
|
||||
.update(calendarEvents)
|
||||
@@ -205,29 +256,42 @@ export async function updateEvent(input: { id: string } & Partial<z.input<typeof
|
||||
})
|
||||
.where(eq(calendarEvents.id, parsed.id));
|
||||
|
||||
await logActivity({
|
||||
await logActivityForScope(scope, {
|
||||
entityType: "calendar.event",
|
||||
entityId: parsed.id,
|
||||
action: "update",
|
||||
payload: parsed.title ? { title: parsed.title } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEvent(input: { id: string } & Partial<z.input<typeof eventInput>>) {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CalendarDto[]> {
|
||||
const { user, household } = await getCurrentSession();
|
||||
export async function listCalendarsForScope(scope: ApiScope): Promise<CalendarDto[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: calendars.id,
|
||||
@@ -67,12 +95,7 @@ export async function listCalendars(): Promise<CalendarDto[]> {
|
||||
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<CalendarDto[]> {
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listEvents(input: {
|
||||
from: Date | string;
|
||||
to: Date | string;
|
||||
calendarIds: "all" | string[];
|
||||
}): Promise<CalendarEventDto[]> {
|
||||
export async function listCalendars(): Promise<CalendarDto[]> {
|
||||
const { user, household } = await getCurrentSession();
|
||||
return listCalendarsForScope({ householdId: household.id, userId: user.id });
|
||||
}
|
||||
|
||||
export async function getCalendarForScope(scope: ApiScope, id: string): Promise<CalendarDto> {
|
||||
const parsed = z.string().uuid().parse(id);
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: calendars.id,
|
||||
name: calendars.name,
|
||||
color: calendars.color,
|
||||
visibility: calendars.visibility,
|
||||
ownerId: calendars.ownerId,
|
||||
householdId: calendars.householdId,
|
||||
})
|
||||
.from(calendars)
|
||||
.where(and(eq(calendars.id, parsed), eq(calendars.householdId, scope.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Calendar not found");
|
||||
if (!(await canSeeCalendarForScope(scope, row.id))) throw new Error("Calendar not found");
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
visibility: row.visibility as "private" | "household",
|
||||
ownerId: row.ownerId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEventsForScope(
|
||||
scope: ApiScope,
|
||||
input: {
|
||||
from: Date | string;
|
||||
to: Date | string;
|
||||
calendarIds: "all" | string[];
|
||||
},
|
||||
): Promise<CalendarEventDto[]> {
|
||||
const parsed = listEventsSchema.parse(input);
|
||||
const visibleCalendars = await 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<CalendarEventDto[]> {
|
||||
const { user, household } = await getCurrentSession();
|
||||
return listEventsForScope({ householdId: household.id, userId: user.id }, input);
|
||||
}
|
||||
|
||||
export async function getEventForScope(scope: ApiScope, id: string): Promise<CalendarEventDto> {
|
||||
const parsed = z.string().uuid().parse(id);
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: calendarEvents.id,
|
||||
calendarId: calendarEvents.calendarId,
|
||||
title: calendarEvents.title,
|
||||
startAt: calendarEvents.startAt,
|
||||
endAt: calendarEvents.endAt,
|
||||
allDay: calendarEvents.allDay,
|
||||
location: calendarEvents.location,
|
||||
notes: calendarEvents.notes,
|
||||
householdId: calendars.householdId,
|
||||
})
|
||||
.from(calendarEvents)
|
||||
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
.where(and(eq(calendarEvents.id, parsed), eq(calendars.householdId, scope.householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Event not found");
|
||||
if (!(await canSeeCalendarForScope(scope, row.calendarId))) throw new Error("Event not found");
|
||||
|
||||
return toEventDto(row);
|
||||
}
|
||||
|
||||
export async function searchCalendars(query: string, householdId: string) {
|
||||
const rows = await db
|
||||
.select({ id: calendars.id, name: calendars.name })
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
@@ -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<typeof listInput>) {
|
||||
export async function createListForScope(scope: ApiAuthContext, input: z.input<typeof listInput>) {
|
||||
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<typeof listInput>) {
|
||||
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<typeof listUpdateInput>,
|
||||
) {
|
||||
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<typeof itemInput>) {
|
||||
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<typeof itemInput>) {
|
||||
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<typeof itemInput>) {
|
||||
.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<typeof itemInput>) {
|
||||
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<string, unknown>,
|
||||
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<typeof updateItemInput>) {
|
||||
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<typeof updateItemInput>,
|
||||
) {
|
||||
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<typeof updateItemInput>) {
|
||||
})
|
||||
.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<typeof updateItemInput>) {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,12 +42,14 @@ const listListsInput = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
export async function listLists(input?: z.input<typeof listListsInput>): Promise<ListDto[]> {
|
||||
export async function listListsForScope(
|
||||
householdId: string,
|
||||
input?: z.input<typeof listListsInput>,
|
||||
): Promise<ListDto[]> {
|
||||
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<typeof listListsInput>): Promise
|
||||
return summarizeLists(rows);
|
||||
}
|
||||
|
||||
export async function getList(id: string): Promise<ListDetailDto> {
|
||||
const parsed = z.string().uuid().parse(id);
|
||||
export async function listLists(input?: z.input<typeof listListsInput>): Promise<ListDto[]> {
|
||||
const { household } = await getCurrentSession();
|
||||
await ensureDefaultListsForHousehold(household.id);
|
||||
return listListsForScope(household.id, input);
|
||||
}
|
||||
|
||||
export async function getListForScope(householdId: string, id: string): Promise<ListDetailDto> {
|
||||
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<ListDetailDto> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getList(id: string): Promise<ListDetailDto> {
|
||||
const { household } = await getCurrentSession();
|
||||
return getListForScope(household.id, id);
|
||||
}
|
||||
|
||||
export async function listItemsForScope(
|
||||
householdId: string,
|
||||
listId: string,
|
||||
): Promise<ListItemDto[]> {
|
||||
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 })
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
if (scope.userId) return scope.userId;
|
||||
|
||||
export async function createNote(input: z.input<typeof noteInput>) {
|
||||
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<typeof noteInput>,
|
||||
): Promise<NoteDto> {
|
||||
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<typeof noteInput>) {
|
||||
|
||||
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<typeof noteInput>) {
|
||||
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<typeof updateNoteInput>) {
|
||||
export async function updateNoteForScope(
|
||||
scope: ApiAuthContext,
|
||||
input: z.input<typeof updateNoteInput>,
|
||||
): Promise<NoteDto> {
|
||||
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<typeof updateNoteInput>) {
|
||||
|
||||
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<typeof updateNoteInput>) {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -18,30 +18,38 @@ export type NoteDto = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function listNotes(): Promise<NoteDto[]> {
|
||||
const { household } = await getCurrentSession();
|
||||
export async function listNotesForScope(householdId: string): Promise<NoteDto[]> {
|
||||
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<NoteDto> {
|
||||
const parsed = z.string().uuid().parse(id);
|
||||
export async function listNotes(): Promise<NoteDto[]> {
|
||||
const { household } = await getCurrentSession();
|
||||
return listNotesForScope(household.id);
|
||||
}
|
||||
|
||||
export async function getNoteForScope(householdId: string, id: string): Promise<NoteDto> {
|
||||
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<NoteDto> {
|
||||
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 })
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user