feat: api v1 routes for calendar lists and notes
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
Reference in New Issue
Block a user