diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 3b05fd9..73bde53 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -1,4 +1,7 @@ import { error } from '@sveltejs/kit'; +import { db } from './db/index.js'; +import { notes, noteCollaborators } from './db/schema.js'; +import { eq, and } from 'drizzle-orm'; type EventWithLocals = { locals: App.Locals; @@ -14,3 +17,36 @@ export function requireAdmin(event: EventWithLocals) { if (!user || user.role !== 'admin') throw error(403, 'Forbidden'); return user; } + +/** + * Check if a user can access a note (as owner or collaborator). + */ +export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } { + const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get(); + if (!note) return { canAccess: false, isOwner: false }; + if (note.userId === userId) return { canAccess: true, isOwner: true }; + + const collab = db + .select({ userId: noteCollaborators.userId }) + .from(noteCollaborators) + .where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId))) + .get(); + return { canAccess: !!collab, isOwner: false }; +} + +/** + * Require that a user can access a note. Throws 404 if no access. + */ +export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } { + const { canAccess, isOwner } = canAccessNote(noteId, userId); + if (!canAccess) throw error(404, 'Not found'); + return { isOwner }; +} + +/** + * Require that a user is the owner of a note. Throws 404 if not owner. + */ +export function requireNoteOwnership(noteId: string, userId: number): void { + const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get(); + if (!note || note.userId !== userId) throw error(404, 'Not found'); +} diff --git a/src/lib/server/collaborators.ts b/src/lib/server/collaborators.ts new file mode 100644 index 0000000..c9d906d --- /dev/null +++ b/src/lib/server/collaborators.ts @@ -0,0 +1,75 @@ +import { db } from './db/index.js'; +import { noteCollaborators, noteUserState, users } from './db/schema.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import type { Collaborator } from '$lib/types/index.js'; + +/** + * Batch fetch collaborators for multiple notes. + * Returns a Map of noteId → Collaborator[]. + */ +export function fetchCollaboratorsForNotes(noteIds: string[]): Map { + if (noteIds.length === 0) return new Map(); + + const rows = db + .select({ + noteId: noteCollaborators.noteId, + userId: noteCollaborators.userId, + displayName: users.displayName, + email: users.email, + addedAt: noteCollaborators.addedAt + }) + .from(noteCollaborators) + .innerJoin(users, eq(noteCollaborators.userId, users.id)) + .where(inArray(noteCollaborators.noteId, noteIds)) + .all(); + + const map = new Map(); + for (const row of rows) { + const collab: Collaborator = { + userId: row.userId, + displayName: row.displayName, + email: row.email, + addedAt: row.addedAt + }; + const existing = map.get(row.noteId); + if (existing) { + existing.push(collab); + } else { + map.set(row.noteId, [collab]); + } + } + return map; +} + +/** + * Add a collaborator to a note. + */ +export function addCollaborator(noteId: string, userId: number, addedBy: number): void { + db.insert(noteCollaborators) + .values({ noteId, userId, addedBy, addedAt: new Date() }) + .run(); +} + +/** + * Remove a collaborator from a note. Also cleans up their per-user state. + */ +export function removeCollaborator(noteId: string, userId: number): void { + db.delete(noteCollaborators) + .where(and(eq(noteCollaborators.noteId, noteId), eq(noteCollaborators.userId, userId))) + .run(); + db.delete(noteUserState) + .where(and(eq(noteUserState.noteId, noteId), eq(noteUserState.userId, userId))) + .run(); +} + +/** + * Get all collaborator user IDs for a note. + */ +export function getCollaboratorIds(noteId: string): number[] { + return db + .select({ userId: noteCollaborators.userId }) + .from(noteCollaborators) + .where(eq(noteCollaborators.noteId, noteId)) + .all() + .map((r) => r.userId); +} diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 3e488af..70f07bc 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -93,6 +93,21 @@ sqlite.exec(` client_id TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS note_collaborators ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + added_by INTEGER NOT NULL REFERENCES users(id), + added_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS note_user_state ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + pinned INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS login_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, @@ -168,6 +183,10 @@ sqlite.exec(` CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp); CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp); + CREATE UNIQUE INDEX IF NOT EXISTS note_collaborators_unique ON note_collaborators(note_id, user_id); + CREATE INDEX IF NOT EXISTS note_collaborators_user_id_idx ON note_collaborators(user_id); + CREATE UNIQUE INDEX IF NOT EXISTS note_user_state_unique ON note_user_state(note_id, user_id); + CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id), diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 49153f9..4dd8c1f 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -128,6 +128,42 @@ export const syncLog = sqliteTable( (table) => [index('sync_log_timestamp_idx').on(table.timestamp)] ); +export const noteCollaborators = sqliteTable( + 'note_collaborators', + { + noteId: text('note_id') + .references(() => notes.id, { onDelete: 'cascade' }) + .notNull(), + userId: integer('user_id') + .references(() => users.id) + .notNull(), + addedBy: integer('added_by') + .references(() => users.id) + .notNull(), + addedAt: integer('added_at', { mode: 'timestamp' }).notNull() + }, + (table) => [ + uniqueIndex('note_collaborators_unique').on(table.noteId, table.userId), + index('note_collaborators_user_id_idx').on(table.userId) + ] +); + +export const noteUserState = sqliteTable( + 'note_user_state', + { + noteId: text('note_id') + .references(() => notes.id, { onDelete: 'cascade' }) + .notNull(), + userId: integer('user_id') + .references(() => users.id) + .notNull(), + pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(), + archived: integer('archived', { mode: 'boolean' }).default(false).notNull(), + sortOrder: integer('sort_order').default(0).notNull() + }, + (table) => [uniqueIndex('note_user_state_unique').on(table.noteId, table.userId)] +); + export const loginAttempts = sqliteTable( 'login_attempts', { diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index f045213..6714faa 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -68,6 +68,24 @@ export function createTestDb(options?: { seedUser?: boolean }) { CREATE INDEX note_tags_note_id_idx ON note_tags(note_id); CREATE INDEX note_tags_tag_id_idx ON note_tags(tag_id); + CREATE TABLE note_collaborators ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + added_by INTEGER NOT NULL REFERENCES users(id), + added_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX note_collaborators_unique ON note_collaborators(note_id, user_id); + CREATE INDEX note_collaborators_user_id_idx ON note_collaborators(user_id); + + CREATE TABLE note_user_state ( + note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id), + pinned INTEGER NOT NULL DEFAULT 0, + archived INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE UNIQUE INDEX note_user_state_unique ON note_user_state(note_id, user_id); + CREATE TABLE attachments ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 8fe9034..47af735 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -14,6 +14,16 @@ export interface Note { version: number; tags?: string[]; attachments?: Attachment[]; + collaborators?: Collaborator[]; + isOwner?: boolean; + isShared?: boolean; +} + +export interface Collaborator { + userId: number; + displayName: string; + email: string; + addedAt: Date; } export interface NoteCreate { diff --git a/src/routes/api/notes/[id]/collaborators/+server.ts b/src/routes/api/notes/[id]/collaborators/+server.ts new file mode 100644 index 0000000..589a4e6 --- /dev/null +++ b/src/routes/api/notes/[id]/collaborators/+server.ts @@ -0,0 +1,72 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js'; +import { addCollaborator, removeCollaborator, fetchCollaboratorsForNotes } from '$lib/server/collaborators.js'; +import { db } from '$lib/server/db/index.js'; +import { users, notes, noteCollaborators } from '$lib/server/db/schema.js'; +import { eq, and } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ params, ...event }) => { + const userId = getUserId(event); + requireNoteAccess(params.id, userId); + + const collaborators = fetchCollaboratorsForNotes([params.id]); + return json(collaborators.get(params.id) ?? []); +}; + +export const POST: RequestHandler = async ({ params, request, ...event }) => { + const userId = getUserId(event); + requireNoteOwnership(params.id, userId); + + const body = await request.json(); + const targetUserId = body.userId; + if (!targetUserId || typeof targetUserId !== 'number') { + throw error(400, 'userId is required'); + } + + // Cannot add yourself + const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, params.id)).get(); + if (note && note.userId === targetUserId) { + throw error(400, 'Cannot add the owner as a collaborator'); + } + + // Target user must exist + const targetUser = db.select({ id: users.id }).from(users).where(eq(users.id, targetUserId)).get(); + if (!targetUser) { + throw error(404, 'User not found'); + } + + // Check not already a collaborator + const existing = db + .select({ userId: noteCollaborators.userId }) + .from(noteCollaborators) + .where(and(eq(noteCollaborators.noteId, params.id), eq(noteCollaborators.userId, targetUserId))) + .get(); + if (existing) { + throw error(409, 'User is already a collaborator'); + } + + addCollaborator(params.id, targetUserId, userId); + + const collaborators = fetchCollaboratorsForNotes([params.id]); + return json(collaborators.get(params.id) ?? [], { status: 201 }); +}; + +export const DELETE: RequestHandler = async ({ params, url, ...event }) => { + const userId = getUserId(event); + const targetUserId = Number(url.searchParams.get('userId')); + if (!targetUserId) { + throw error(400, 'userId query parameter is required'); + } + + const { isOwner } = requireNoteAccess(params.id, userId); + + // Owner can remove anyone. Collaborator can only remove self. + if (!isOwner && targetUserId !== userId) { + throw error(403, 'Only the owner can remove other collaborators'); + } + + removeCollaborator(params.id, targetUserId); + + return json({ success: true }); +}; diff --git a/src/routes/api/users/search/+server.ts b/src/routes/api/users/search/+server.ts new file mode 100644 index 0000000..5425aa1 --- /dev/null +++ b/src/routes/api/users/search/+server.ts @@ -0,0 +1,35 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { getUserId } from '$lib/server/api-utils.js'; +import { db } from '$lib/server/db/index.js'; +import { users } from '$lib/server/db/schema.js'; +import { ne } from 'drizzle-orm'; + +export const GET: RequestHandler = async ({ url, ...event }) => { + const userId = getUserId(event); + const query = url.searchParams.get('q')?.trim(); + if (!query || query.length < 1) { + return json([]); + } + + const pattern = `%${query}%`; + const results = db + .select({ + id: users.id, + displayName: users.displayName, + email: users.email + }) + .from(users) + .where( + ne(users.id, userId) + ) + .all() + .filter( + (u) => + u.displayName.toLowerCase().includes(query.toLowerCase()) || + u.email.toLowerCase().includes(query.toLowerCase()) + ) + .slice(0, 10); + + return json(results); +};