From 7ef4e00c4fe7ac1cf058dff68da9a29dbedddfd5 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Mon, 9 Mar 2026 11:57:06 +0100 Subject: [PATCH] feat: widen notes service and sync for shared notes - Refactor notes-service to include shared notes in list/get/search/reorder - Route per-user fields (pinned/archived/sortOrder) to noteUserState for collaborators - Update API routes to delegate to service layer with access control - Update sync server to handle shared note updates bidirectionally - Add leaveNote() to client store - Refactor search and notes API routes to use service layer Co-Authored-By: Claude Opus 4.6 --- src/lib/server/notes-service.ts | 336 ++++++++++++++---- src/lib/stores/notes.ts | 15 + src/lib/sync/server.ts | 128 ++++++- src/routes/api/notes/+server.ts | 74 +--- src/routes/api/notes/[id]/+server.ts | 89 +---- .../api/notes/[id]/attachments/+server.ts | 24 +- .../api/notes/[id]/collaborators/+server.ts | 3 +- src/routes/api/search/+server.ts | 51 +-- 8 files changed, 415 insertions(+), 305 deletions(-) diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts index c63f79b..21bb741 100644 --- a/src/lib/server/notes-service.ts +++ b/src/lib/server/notes-service.ts @@ -1,59 +1,158 @@ import { db as defaultDb } from './db/index.js'; -import { notes, noteTags, tags } from './db/schema.js'; -import { eq, and, desc, like, or, inArray } from 'drizzle-orm'; +import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js'; +import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; import { extractTags } from '$lib/utils/tags.js'; import { fetchTagsForNotes, syncNoteTags } from './tags.js'; import { fetchAttachmentsForNotes } from './attachments.js'; +import { fetchCollaboratorsForNotes } from './collaborators.js'; +import { canAccessNote } from './api-utils.js'; import type { NoteFilter } from '$lib/types/index.js'; const db = defaultDb; +interface NoteRow { + id: string; + userId: number; + title: string; + content: string; + color: string; + pinned: boolean; + archived: boolean; + trashed: boolean; + trashedAt: Date | null; + checklistMode: boolean; + sortOrder: number; + createdAt: Date; + updatedAt: Date; + version: number; +} + +function hydrateNotes(noteRows: NoteRow[], userId: number) { + const noteIds = noteRows.map((n) => n.id); + const tagMap = fetchTagsForNotes(noteIds); + const attachmentMap = fetchAttachmentsForNotes(noteIds); + const collabMap = fetchCollaboratorsForNotes(noteIds); + + return noteRows.map((note) => { + const collaborators = collabMap.get(note.id) ?? []; + const isOwner = note.userId === userId; + const isShared = collaborators.length > 0; + return { + ...note, + tags: tagMap.get(note.id) ?? [], + attachments: attachmentMap.get(note.id) ?? [], + collaborators, + isOwner, + isShared + }; + }); +} + +/** + * Get shared notes for a user with per-user state overlay. + */ +function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] { + const rows = db + .select({ + id: notes.id, + userId: notes.userId, + title: notes.title, + content: notes.content, + color: notes.color, + pinned: sql`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'), + archived: sql`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'), + trashed: notes.trashed, + trashedAt: notes.trashedAt, + checklistMode: notes.checklistMode, + sortOrder: sql`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'), + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + version: notes.version + }) + .from(noteCollaborators) + .innerJoin(notes, eq(noteCollaborators.noteId, notes.id)) + .leftJoin( + noteUserState, + and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId)) + ) + .where( + and( + eq(noteCollaborators.userId, userId), + eq(notes.trashed, false), + filter === 'archived' + ? sql`COALESCE(${noteUserState.archived}, 0) = 1` + : sql`COALESCE(${noteUserState.archived}, 0) = 0` + ) + ) + .all(); + + return rows as NoteRow[]; +} + export function listNotes(userId: number, filter: NoteFilter = 'all') { + // Owned notes let conditions; switch (filter) { case 'archived': conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false)); break; case 'trashed': + // Collaborators never see trashed notes — only owner's trashed notes conditions = and(eq(notes.userId, userId), eq(notes.trashed, true)); break; default: conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false)); } - const result = db + const ownedNotes = db .select() .from(notes) .where(conditions) - .orderBy(desc(notes.pinned), desc(notes.updatedAt)) - .all(); + .all() as NoteRow[]; - const noteIds = result.map((n) => n.id); - const tagMap = fetchTagsForNotes(noteIds); - const attachmentMap = fetchAttachmentsForNotes(noteIds); - return result.map((note) => ({ - ...note, - tags: tagMap.get(note.id) ?? [], - attachments: attachmentMap.get(note.id) ?? [] - })); + // Shared notes (not for trashed filter — collaborators don't see trash) + let sharedNotes: NoteRow[] = []; + if (filter !== 'trashed') { + sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all'); + } + + const combined = [...ownedNotes, ...sharedNotes]; + combined.sort((a, b) => { + // Pinned first, then by updatedAt descending + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; + return b.updatedAt.getTime() - a.updatedAt.getTime(); + }); + + return hydrateNotes(combined, userId); } export function getNote(userId: number, id: string) { - const note = db - .select() - .from(notes) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .get(); + const { canAccess, isOwner } = canAccessNote(id, userId); + if (!canAccess) return null; + + const note = db.select().from(notes).where(eq(notes.id, id)).get(); if (!note) return null; - const tagMap = fetchTagsForNotes([id]); - const attachmentMap = fetchAttachmentsForNotes([id]); - return { - ...note, - tags: tagMap.get(id) ?? [], - attachments: attachmentMap.get(id) ?? [] - }; + let effectiveNote = note as NoteRow; + + // For collaborators, overlay per-user state + if (!isOwner) { + const userState = db + .select() + .from(noteUserState) + .where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId))) + .get(); + effectiveNote = { + ...note, + pinned: userState?.pinned ?? false, + archived: userState?.archived ?? false, + sortOrder: userState?.sortOrder ?? 0 + }; + } + + const result = hydrateNotes([effectiveNote], userId); + return result[0] ?? null; } export interface CreateNoteInput { @@ -109,56 +208,94 @@ export interface UpdateNoteInput { sortOrder?: number; } +/** Per-user fields that go to noteUserState for collaborators */ +const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const; + export function updateNote(userId: number, id: string, input: UpdateNoteInput) { - const existing = db - .select() - .from(notes) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .get(); + const { canAccess, isOwner } = canAccessNote(id, userId); + if (!canAccess) return null; + + const existing = db.select().from(notes).where(eq(notes.id, id)).get(); if (!existing) return null; + // Collaborators cannot trash notes + if (!isOwner && input.trashed !== undefined) { + return null; + } + const now = new Date(); - const updates: Record = { + + // Shared fields: update the notes table (all participants can edit content) + const sharedUpdates: Record = { updatedAt: now, version: existing.version + 1 }; + let hasSharedUpdates = false; - if (input.title !== undefined) updates.title = input.title; - if (input.content !== undefined) updates.content = input.content; - if (input.color !== undefined) updates.color = input.color; - if (input.pinned !== undefined) updates.pinned = input.pinned; - if (input.archived !== undefined) updates.archived = input.archived; - if (input.trashed !== undefined) { - updates.trashed = input.trashed; - updates.trashedAt = input.trashed ? now : null; + if (input.title !== undefined) { sharedUpdates.title = input.title; hasSharedUpdates = true; } + if (input.content !== undefined) { sharedUpdates.content = input.content; hasSharedUpdates = true; } + if (input.color !== undefined) { sharedUpdates.color = input.color; hasSharedUpdates = true; } + if (input.checklistMode !== undefined) { sharedUpdates.checklistMode = input.checklistMode; hasSharedUpdates = true; } + + if (isOwner) { + // Owner: per-user fields go to notes table directly + if (input.pinned !== undefined) { sharedUpdates.pinned = input.pinned; hasSharedUpdates = true; } + if (input.archived !== undefined) { sharedUpdates.archived = input.archived; hasSharedUpdates = true; } + if (input.trashed !== undefined) { + sharedUpdates.trashed = input.trashed; + sharedUpdates.trashedAt = input.trashed ? now : null; + hasSharedUpdates = true; + } + if (input.sortOrder !== undefined) { sharedUpdates.sortOrder = input.sortOrder; hasSharedUpdates = true; } } - if (input.checklistMode !== undefined) updates.checklistMode = input.checklistMode; - if (input.sortOrder !== undefined) updates.sortOrder = input.sortOrder; - db.update(notes) - .set(updates) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .run(); + if (hasSharedUpdates || isOwner) { + db.update(notes).set(sharedUpdates).where(eq(notes.id, id)).run(); + } - const updated = db - .select() - .from(notes) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .get(); + // Collaborator: per-user fields go to noteUserState + if (!isOwner) { + const hasPerUserFields = PER_USER_FIELDS.some((f) => input[f] !== undefined); + if (hasPerUserFields) { + const userState = db + .select() + .from(noteUserState) + .where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId))) + .get(); + + if (userState) { + const stateUpdates: Record = {}; + if (input.pinned !== undefined) stateUpdates.pinned = input.pinned; + if (input.archived !== undefined) stateUpdates.archived = input.archived; + if (input.sortOrder !== undefined) stateUpdates.sortOrder = input.sortOrder; + db.update(noteUserState) + .set(stateUpdates) + .where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId))) + .run(); + } else { + db.insert(noteUserState) + .values({ + noteId: id, + userId, + pinned: input.pinned ?? false, + archived: input.archived ?? false, + sortOrder: input.sortOrder ?? 0 + }) + .run(); + } + } + } if (input.title !== undefined || input.content !== undefined) { - const content = `${updated!.title} ${updated!.content}`; - const extractedTags = extractTags(content); - syncNoteTags(id, extractedTags, userId); + const updated = db.select().from(notes).where(eq(notes.id, id)).get(); + if (updated) { + const content = `${updated.title} ${updated.content}`; + const extractedTags = extractTags(content); + syncNoteTags(id, extractedTags, existing.userId); + } } - const tagMap = fetchTagsForNotes([id]); - const attachmentMap = fetchAttachmentsForNotes([id]); - return { - ...updated, - tags: tagMap.get(id) ?? [], - attachments: attachmentMap.get(id) ?? [] - }; + return getNote(userId, id); } export function deleteNote(userId: number, id: string): boolean { @@ -180,7 +317,8 @@ export function searchNotes(userId: number, query: string) { const pattern = `%${query}%`; - const results = db + // Owned notes matching query + const ownedResults = db .select() .from(notes) .where( @@ -192,6 +330,40 @@ export function searchNotes(userId: number, query: string) { ) .all(); + // Shared notes matching query + const sharedResults = db + .select({ + id: notes.id, + userId: notes.userId, + title: notes.title, + content: notes.content, + color: notes.color, + pinned: sql`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'), + archived: sql`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'), + trashed: notes.trashed, + trashedAt: notes.trashedAt, + checklistMode: notes.checklistMode, + sortOrder: sql`COALESCE(${noteUserState.sortOrder}, 0)`.as('user_sort_order'), + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + version: notes.version + }) + .from(noteCollaborators) + .innerJoin(notes, eq(noteCollaborators.noteId, notes.id)) + .leftJoin( + noteUserState, + and(eq(noteUserState.noteId, notes.id), eq(noteUserState.userId, userId)) + ) + .where( + and( + eq(noteCollaborators.userId, userId), + eq(notes.trashed, false), + or(like(notes.title, pattern), like(notes.content, pattern)) + ) + ) + .all(); + + // Tag search for owned notes const tagResults = db .select({ noteId: noteTags.noteId }) .from(noteTags) @@ -199,12 +371,12 @@ export function searchNotes(userId: number, query: string) { .where(and(like(tags.name, pattern), eq(tags.userId, userId))) .all(); - const resultIds = new Set(results.map((n) => n.id)); + const resultIds = new Set([...ownedResults.map((n) => n.id), ...sharedResults.map((n) => n.id)]); const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter( (id) => !resultIds.has(id) ); - let extraNotes: typeof results = []; + let extraNotes: typeof ownedResults = []; if (extraNoteIds.length > 0) { extraNotes = db .select() @@ -213,12 +385,8 @@ export function searchNotes(userId: number, query: string) { .all(); } - const combined = [...results, ...extraNotes]; - const tagMap = fetchTagsForNotes(combined.map((n) => n.id)); - return combined.map((note) => ({ - ...note, - tags: tagMap.get(note.id) ?? [] - })); + const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[]; + return hydrateNotes(combined, userId); } export function listAllTags(userId: number) { @@ -228,9 +396,31 @@ export function listAllTags(userId: number) { export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) { const now = new Date(); for (const { id, sortOrder } of orders) { - db.update(notes) - .set({ sortOrder, updatedAt: now }) - .where(and(eq(notes.id, id), eq(notes.userId, userId))) - .run(); + const { canAccess, isOwner } = canAccessNote(id, userId); + if (!canAccess) continue; + + if (isOwner) { + db.update(notes) + .set({ sortOrder, updatedAt: now }) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .run(); + } else { + // Collaborator: upsert into noteUserState + const existing = db + .select() + .from(noteUserState) + .where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId))) + .get(); + if (existing) { + db.update(noteUserState) + .set({ sortOrder }) + .where(and(eq(noteUserState.noteId, id), eq(noteUserState.userId, userId))) + .run(); + } else { + db.insert(noteUserState) + .values({ noteId: id, userId, pinned: false, archived: false, sortOrder }) + .run(); + } + } } } diff --git a/src/lib/stores/notes.ts b/src/lib/stores/notes.ts index bd4d461..7cece38 100644 --- a/src/lib/stores/notes.ts +++ b/src/lib/stores/notes.ts @@ -233,6 +233,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise { + try { + const res = await fetch(`/api/notes/${id}/collaborators?userId=self`, { method: 'DELETE' }); + if (res.ok) { + notes.update((list) => list.filter((n) => n.id !== id)); + await deleteNoteFromIdb(id); + return true; + } + showToast('Failed to leave note', 'error'); + return false; + } catch { + return false; + } +} + export async function updateSortOrders(orders: { id: string; sortOrder: number }[]): Promise { // Optimistic update notes.update((list) => diff --git a/src/lib/sync/server.ts b/src/lib/sync/server.ts index 9bb70e9..00f6fb1 100644 --- a/src/lib/sync/server.ts +++ b/src/lib/sync/server.ts @@ -1,8 +1,9 @@ import { db } from '$lib/server/db/index.js'; -import { notes, syncLog } from '$lib/server/db/schema.js'; -import { eq, gt, and } from 'drizzle-orm'; +import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js'; +import { eq, gt, and, or } from 'drizzle-orm'; import { extractTags } from '$lib/utils/tags.js'; import { syncNoteTags } from '$lib/server/tags.js'; +import { canAccessNote } from '$lib/server/api-utils.js'; import type { SyncQueueItem } from './idb.js'; /** @@ -17,24 +18,78 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number): case 'create': case 'update': { if (!change.data) continue; - const existing = tx - .select() - .from(notes) - .where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))) - .get(); - if (existing) { - if (change.timestamp > existing.updatedAt.getTime()) { + // Check access: owner can update all fields, collaborator can update shared fields + const note = tx.select().from(notes).where(eq(notes.id, change.noteId)).get(); + + if (note) { + const isOwner = note.userId === userId; + const isCollaborator = !isOwner && tx + .select({ userId: noteCollaborators.userId }) + .from(noteCollaborators) + .where(and(eq(noteCollaborators.noteId, change.noteId), eq(noteCollaborators.userId, userId))) + .get(); + + if (!isOwner && !isCollaborator) continue; + + if (change.timestamp > note.updatedAt.getTime()) { + // Shared fields: title, content, color, checklistMode + const sharedData: Record = { + updatedAt: new Date(change.timestamp), + version: note.version + 1 + }; + if (change.data.title !== undefined) sharedData.title = change.data.title; + if (change.data.content !== undefined) sharedData.content = change.data.content; + if (change.data.color !== undefined) sharedData.color = change.data.color; + if (change.data.checklistMode !== undefined) sharedData.checklistMode = change.data.checklistMode; + + if (isOwner) { + // Owner: per-user fields go to notes table + if (change.data.pinned !== undefined) sharedData.pinned = change.data.pinned; + if (change.data.archived !== undefined) sharedData.archived = change.data.archived; + if (change.data.trashed !== undefined) { + sharedData.trashed = change.data.trashed; + sharedData.trashedAt = change.data.trashed ? new Date(change.timestamp) : null; + } + if (change.data.sortOrder !== undefined) sharedData.sortOrder = change.data.sortOrder; + } + tx.update(notes) - .set({ - ...change.data, - updatedAt: new Date(change.timestamp), - version: existing.version + 1 - }) - .where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))) + .set(sharedData) + .where(eq(notes.id, change.noteId)) .run(); + + // Collaborator: per-user fields go to noteUserState + if (!isOwner) { + const hasPerUser = change.data.pinned !== undefined || + change.data.archived !== undefined || + change.data.sortOrder !== undefined; + if (hasPerUser) { + const existing = tx.select().from(noteUserState) + .where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId))) + .get(); + if (existing) { + const updates: Record = {}; + if (change.data.pinned !== undefined) updates.pinned = change.data.pinned; + if (change.data.archived !== undefined) updates.archived = change.data.archived; + if (change.data.sortOrder !== undefined) updates.sortOrder = change.data.sortOrder; + tx.update(noteUserState).set(updates) + .where(and(eq(noteUserState.noteId, change.noteId), eq(noteUserState.userId, userId))) + .run(); + } else { + tx.insert(noteUserState).values({ + noteId: change.noteId, + userId, + pinned: change.data.pinned ?? false, + archived: change.data.archived ?? false, + sortOrder: change.data.sortOrder ?? 0 + }).run(); + } + } + } } } else if (change.operation === 'create' && change.data) { + // Only owner can create notes tx.insert(notes) .values({ id: change.noteId, @@ -60,6 +115,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number): break; } case 'delete': { + // Only owner can delete tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run(); break; } @@ -78,21 +134,55 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number): }); for (const noteId of noteIdsToSyncTags) { - const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get(); + const note = db.select().from(notes).where(eq(notes.id, noteId)).get(); if (note) { const content = `${note.title} ${note.content}`; - syncNoteTags(noteId, extractTags(content), userId); + syncNoteTags(noteId, extractTags(content), note.userId); } } } /** * Get all notes updated since a given timestamp for a specific user. + * Includes both owned and shared notes. */ export async function getChangesSince(sinceTimestamp: number, userId: number) { - return db + const since = new Date(sinceTimestamp); + + // Owned notes + const ownedNotes = db .select() .from(notes) - .where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp)))) + .where(and(eq(notes.userId, userId), gt(notes.updatedAt, since))) .all(); + + // Shared notes (via collaborator relationship) + const sharedNotes = db + .select({ + id: notes.id, + userId: notes.userId, + title: notes.title, + content: notes.content, + color: notes.color, + pinned: notes.pinned, + archived: notes.archived, + trashed: notes.trashed, + trashedAt: notes.trashedAt, + checklistMode: notes.checklistMode, + sortOrder: notes.sortOrder, + createdAt: notes.createdAt, + updatedAt: notes.updatedAt, + version: notes.version + }) + .from(noteCollaborators) + .innerJoin(notes, eq(noteCollaborators.noteId, notes.id)) + .where( + and( + eq(noteCollaborators.userId, userId), + gt(notes.updatedAt, since) + ) + ) + .all(); + + return [...ownedNotes, ...sharedNotes]; } diff --git a/src/routes/api/notes/+server.ts b/src/routes/api/notes/+server.ts index 11016c6..79bcff7 100644 --- a/src/routes/api/notes/+server.ts +++ b/src/routes/api/notes/+server.ts @@ -1,79 +1,19 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { db } from '$lib/server/db/index.js'; -import { notes } from '$lib/server/db/schema.js'; -import { eq, and, desc } from 'drizzle-orm'; -import { v4 as uuidv4 } from 'uuid'; -import { extractTags } from '$lib/utils/tags.js'; -import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js'; -import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; import { getUserId } from '$lib/server/api-utils.js'; +import { listNotes, createNote } from '$lib/server/notes-service.js'; +import type { NoteFilter } from '$lib/types/index.js'; export const GET: RequestHandler = async ({ url, ...event }) => { const userId = getUserId(event); - const filter = url.searchParams.get('filter') || 'all'; - - let conditions; - switch (filter) { - case 'archived': - conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false)); - break; - case 'trashed': - conditions = and(eq(notes.userId, userId), eq(notes.trashed, true)); - break; - default: - conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false)); - } - - const result = db - .select() - .from(notes) - .where(conditions) - .orderBy(desc(notes.pinned), desc(notes.updatedAt)) - .all(); - - const noteIds = result.map((n) => n.id); - const tagMap = fetchTagsForNotes(noteIds); - const attachmentMap = fetchAttachmentsForNotes(noteIds); - const notesWithTags = result.map((note) => ({ - ...note, - tags: tagMap.get(note.id) ?? [], - attachments: attachmentMap.get(note.id) ?? [] - })); - - return json(notesWithTags); + const filter = (url.searchParams.get('filter') || 'all') as NoteFilter; + const result = listNotes(userId, filter); + return json(result); }; export const POST: RequestHandler = async ({ request, ...event }) => { const userId = getUserId(event); const body = await request.json(); - const now = new Date(); - const id = body.id || uuidv4(); - - const newNote = { - id, - userId, - title: body.title || '', - content: body.content || '', - color: body.color || 'default', - pinned: body.pinned || false, - archived: false, - trashed: false, - trashedAt: null, - checklistMode: body.checklistMode || false, - sortOrder: body.sortOrder || 0, - createdAt: now, - updatedAt: now, - version: 1 - }; - - const content = `${newNote.title} ${newNote.content}`; - const extractedTags = extractTags(content); - - db.transaction((tx) => { - tx.insert(notes).values(newNote).run(); - }); - syncNoteTags(id, extractedTags, userId); - - return json({ ...newNote, tags: extractedTags }, { status: 201 }); + const newNote = createNote(userId, body); + return json(newNote, { status: 201 }); }; diff --git a/src/routes/api/notes/[id]/+server.ts b/src/routes/api/notes/[id]/+server.ts index 5d8e693..4fa1ee4 100644 --- a/src/routes/api/notes/[id]/+server.ts +++ b/src/routes/api/notes/[id]/+server.ts @@ -1,98 +1,31 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { db } from '$lib/server/db/index.js'; -import { notes } from '$lib/server/db/schema.js'; -import { eq, and } from 'drizzle-orm'; -import { extractTags } from '$lib/utils/tags.js'; -import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js'; -import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; -import { getUserId } from '$lib/server/api-utils.js'; +import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js'; +import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js'; export const GET: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); - const note = db - .select() - .from(notes) - .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) - .get(); + const note = getNote(userId, params.id); + if (!note) throw error(404, 'Note not found'); - if (!note) { - throw error(404, 'Note not found'); - } - - const tagMap = fetchTagsForNotes([note.id]); - const attachmentMap = fetchAttachmentsForNotes([note.id]); - - return json({ - ...note, - tags: tagMap.get(note.id) ?? [], - attachments: attachmentMap.get(note.id) ?? [] - }); + return json(note); }; export const PATCH: RequestHandler = async ({ params, request, ...event }) => { const userId = getUserId(event); const body = await request.json(); - const existing = db - .select() - .from(notes) - .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) - .get(); + const updated = updateNote(userId, params.id, body); + if (!updated) throw error(404, 'Note not found'); - if (!existing) { - throw error(404, 'Note not found'); - } - - const now = new Date(); - const updates: Record = { - updatedAt: now, - version: existing.version + 1 - }; - - if (body.title !== undefined) updates.title = body.title; - if (body.content !== undefined) updates.content = body.content; - if (body.color !== undefined) updates.color = body.color; - if (body.pinned !== undefined) updates.pinned = body.pinned; - if (body.archived !== undefined) updates.archived = body.archived; - if (body.trashed !== undefined) { - updates.trashed = body.trashed; - updates.trashedAt = body.trashed ? now : null; - } - if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode; - if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder; - - db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run(); - - const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get(); - - if (body.title !== undefined || body.content !== undefined) { - const content = `${updated!.title} ${updated!.content}`; - const extractedTags = extractTags(content); - syncNoteTags(params.id, extractedTags, userId); - } - - const tagMap = fetchTagsForNotes([params.id]); - const attachmentMap = fetchAttachmentsForNotes([params.id]); - - return json({ - ...updated, - tags: tagMap.get(params.id) ?? [], - attachments: attachmentMap.get(params.id) ?? [] - }); + return json(updated); }; export const DELETE: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); - const existing = db - .select() - .from(notes) - .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) - .get(); + requireNoteOwnership(params.id, userId); - if (!existing) { - throw error(404, 'Note not found'); - } + const deleted = deleteNote(userId, params.id); + if (!deleted) throw error(404, 'Note not found'); - db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run(); return json({ success: true }); }; diff --git a/src/routes/api/notes/[id]/attachments/+server.ts b/src/routes/api/notes/[id]/attachments/+server.ts index cc7ef7c..438cafd 100644 --- a/src/routes/api/notes/[id]/attachments/+server.ts +++ b/src/routes/api/notes/[id]/attachments/+server.ts @@ -2,25 +2,11 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js'; import { readFile } from 'fs/promises'; -import { db } from '$lib/server/db/index.js'; -import { notes } from '$lib/server/db/schema.js'; -import { eq, and } from 'drizzle-orm'; -import { getUserId } from '$lib/server/api-utils.js'; - -/** Verify the note belongs to the authenticated user */ -async function verifyNoteOwnership(noteId: string, userId: number) { - const note = await db - .select() - .from(notes) - .where(and(eq(notes.id, noteId), eq(notes.userId, userId))) - .get(); - if (!note) throw error(404, 'Note not found'); - return note; -} +import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js'; export const GET: RequestHandler = async ({ params, url, ...event }) => { const userId = getUserId(event); - await verifyNoteOwnership(params.id, userId); + requireNoteAccess(params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); @@ -48,7 +34,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => { export const POST: RequestHandler = async ({ params, request, ...event }) => { const userId = getUserId(event); - await verifyNoteOwnership(params.id, userId); + requireNoteAccess(params.id, userId); const formData = await request.formData(); const file = formData.get('file') as File; @@ -73,7 +59,7 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => { export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => { const userId = getUserId(event); - await verifyNoteOwnership(params.id, userId); + requireNoteAccess(params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); @@ -89,7 +75,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event }) export const DELETE: RequestHandler = async ({ params, url, ...event }) => { const userId = getUserId(event); - await verifyNoteOwnership(params.id, userId); + requireNoteAccess(params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); diff --git a/src/routes/api/notes/[id]/collaborators/+server.ts b/src/routes/api/notes/[id]/collaborators/+server.ts index 589a4e6..7fda835 100644 --- a/src/routes/api/notes/[id]/collaborators/+server.ts +++ b/src/routes/api/notes/[id]/collaborators/+server.ts @@ -54,7 +54,8 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => { export const DELETE: RequestHandler = async ({ params, url, ...event }) => { const userId = getUserId(event); - const targetUserId = Number(url.searchParams.get('userId')); + const userIdParam = url.searchParams.get('userId'); + const targetUserId = userIdParam === 'self' ? userId : Number(userIdParam); if (!targetUserId) { throw error(400, 'userId query parameter is required'); } diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 419888d..cf76b4b 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -1,10 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; -import { db } from '$lib/server/db/index.js'; -import { notes, noteTags, tags } from '$lib/server/db/schema.js'; -import { and, eq, like, or, inArray } from 'drizzle-orm'; -import { fetchTagsForNotes } from '$lib/server/tags.js'; import { getUserId } from '$lib/server/api-utils.js'; +import { searchNotes } from '$lib/server/notes-service.js'; export const GET: RequestHandler = async ({ url, ...event }) => { const userId = getUserId(event); @@ -14,48 +11,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => { return json([]); } - const pattern = `%${query}%`; - - const results = db - .select() - .from(notes) - .where( - and( - eq(notes.userId, userId), - eq(notes.trashed, false), - or(like(notes.title, pattern), like(notes.content, pattern)) - ) - ) - .all(); - - // Also search by tag name - const tagResults = db - .select({ noteId: noteTags.noteId }) - .from(noteTags) - .innerJoin(tags, eq(noteTags.tagId, tags.id)) - .where(and(like(tags.name, pattern), eq(tags.userId, userId))) - .all(); - - const resultIds = new Set(results.map((n) => n.id)); - const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter( - (id) => !resultIds.has(id) - ); - - let extraNotes: typeof results = []; - if (extraNoteIds.length > 0) { - extraNotes = db - .select() - .from(notes) - .where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false))) - .all(); - } - - const combined = [...results, ...extraNotes]; - const tagMap = fetchTagsForNotes(combined.map((n) => n.id)); - const withTags = combined.map((note) => ({ - ...note, - tags: tagMap.get(note.id) ?? [] - })); - - return json(withTags); + const results = searchNotes(userId, query); + return json(results); };