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
+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(),
});