mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
a612a16b81
commit
7ef4e00c4f
+263
-73
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,21 @@ export async function togglePin(id: string, currentPinned: boolean): Promise<Not
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
|
||||
export async function leaveNote(id: string): Promise<boolean> {
|
||||
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<boolean> {
|
||||
// Optimistic update
|
||||
notes.update((list) =>
|
||||
|
||||
+109
-19
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user