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
+19
View File
@@ -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,
});
}
+150 -86
View File
@@ -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");
}
+106 -14
View File
@@ -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 })
+40
View File
@@ -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",
},
);
+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(),
};
}
+27 -8
View File
@@ -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 })
+31
View File
@@ -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(),
});
+1 -6
View File
@@ -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;
}
+92 -46
View File
@@ -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");
}
+14 -6
View File
@@ -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 })
+16
View File
@@ -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(),
});