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 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-09 12:12:01 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent a612a16b81
commit 7ef4e00c4f
8 changed files with 415 additions and 305 deletions
+263 -73
View File
@@ -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<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`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<string, unknown> = {
// Shared fields: update the notes table (all participants can edit content)
const sharedUpdates: Record<string, unknown> = {
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<string, unknown> = {};
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<boolean>`COALESCE(${noteUserState.pinned}, 0)`.as('user_pinned'),
archived: sql<boolean>`COALESCE(${noteUserState.archived}, 0)`.as('user_archived'),
trashed: notes.trashed,
trashedAt: notes.trashedAt,
checklistMode: notes.checklistMode,
sortOrder: sql<number>`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();
}
}
}
}