feat: api v1 routes for calendar lists and notes

This commit is contained in:
ginnoir
2026-07-04 19:03:08 -05:00
parent ea5d1d050c
commit d4304b005c
25 changed files with 1141 additions and 236 deletions
+148 -69
View File
@@ -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(),
};
}