feat: add note collaboration schema, access control, and API routes

Add noteCollaborators and noteUserState tables for sharing notes between
users. Implement access control helpers (canAccessNote, requireNoteAccess,
requireNoteOwnership) and collaborators CRUD service. Create API endpoints
for managing collaborators and searching users.

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 a1ae3a0069
commit a612a16b81
8 changed files with 301 additions and 0 deletions
@@ -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 });
};