diff --git a/drizzle/0001_strange_lord_hawal.sql b/drizzle/0001_strange_lord_hawal.sql new file mode 100644 index 0000000..8232865 --- /dev/null +++ b/drizzle/0001_strange_lord_hawal.sql @@ -0,0 +1,37 @@ +CREATE TABLE `login_attempts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `ip` text NOT NULL, + `email` text NOT NULL, + `success` integer NOT NULL, + `timestamp` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint +DROP INDEX `tags_name_unique`;--> statement-breakpoint +ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint +CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_users` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `email` text DEFAULT '' NOT NULL, + `display_name` text DEFAULT '' NOT NULL, + `role` text DEFAULT 'user' NOT NULL, + `password_hash` text, + `auth_provider` text DEFAULT 'password' NOT NULL, + `provider_id` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint +DROP TABLE `users`;--> statement-breakpoint +ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint +ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint +CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint +ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint +ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint +ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint +ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..d799f6c --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,684 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3", + "prevId": "682b02aa-a28d-4904-8996-cbc4d740f626", + "tables": { + "attachments": { + "name": "attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note_id": { + "name": "note_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "attachments_user_id_users_id_fk": { + "name": "attachments_user_id_users_id_fk", + "tableFrom": "attachments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "attachments_note_id_notes_id_fk": { + "name": "attachments_note_id_notes_id_fk", + "tableFrom": "attachments", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "login_attempts": { + "name": "login_attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "success": { + "name": "success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "login_attempts_ip_timestamp_idx": { + "name": "login_attempts_ip_timestamp_idx", + "columns": [ + "ip", + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "note_tags": { + "name": "note_tags", + "columns": { + "note_id": { + "name": "note_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "note_tags_note_id_idx": { + "name": "note_tags_note_id_idx", + "columns": [ + "note_id" + ], + "isUnique": false + }, + "note_tags_tag_id_idx": { + "name": "note_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "note_tags_note_id_notes_id_fk": { + "name": "note_tags_note_id_notes_id_fk", + "tableFrom": "note_tags", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "note_tags_tag_id_tags_id_fk": { + "name": "note_tags_tag_id_tags_id_fk", + "tableFrom": "note_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'default'" + }, + "pinned": { + "name": "pinned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "trashed": { + "name": "trashed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "trashed_at": { + "name": "trashed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checklist_mode": { + "name": "checklist_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "notes_user_id_idx": { + "name": "notes_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "notes_trashed_archived_idx": { + "name": "notes_trashed_archived_idx", + "columns": [ + "trashed", + "archived" + ], + "isUnique": false + }, + "notes_updated_at_idx": { + "name": "notes_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notes_user_id_users_id_fk": { + "name": "notes_user_id_users_id_fk", + "tableFrom": "notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_log": { + "name": "sync_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note_id": { + "name": "note_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timestamp": { + "name": "timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sync_log_timestamp_idx": { + "name": "sync_log_timestamp_idx", + "columns": [ + "timestamp" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sync_log_user_id_users_id_fk": { + "name": "sync_log_user_id_users_id_fk", + "tableFrom": "sync_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "sync_log_note_id_notes_id_fk": { + "name": "sync_log_note_id_notes_id_fk", + "tableFrom": "sync_log", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_name_user_unique": { + "name": "tags_name_user_unique", + "columns": [ + "name", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_provider": { + "name": "auth_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'password'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 377f5a8..a50a8f1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1773000424830, "tag": "0000_wooden_vargas", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1773016788899, + "tag": "0001_strange_lord_hawal", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/server/attachments.ts b/src/lib/server/attachments.ts index e63549b..aaacbf1 100644 --- a/src/lib/server/attachments.ts +++ b/src/lib/server/attachments.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { v4 as uuidv4 } from 'uuid'; import { db } from './db/index.js'; import { attachments } from './db/schema.js'; -import { eq, inArray } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import type { Attachment } from '$lib/types/index.js'; const DATA_DIR = process.env.DATA_DIR || './data'; @@ -80,25 +80,29 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map; beforeEach(() => { - const testDb = createTestDb(); + const testDb = createTestDb({ seedUser: true }); db = testDb.db; }); @@ -20,6 +20,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'Test Note', content: 'Hello world', createdAt: now, @@ -45,6 +46,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'Colored Note', content: '', color: 'coral', @@ -62,16 +64,17 @@ describe('Notes CRUD', () => { it('should read all active notes (not archived, not trashed)', async () => { const now = new Date(); await db.insert(notes).values([ - { id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now }, + { id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now }, { id: 'n2', + userId: 1, title: 'Archived', content: '', archived: true, createdAt: now, updatedAt: now }, - { id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now } + { id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now } ]); const active = await db @@ -89,6 +92,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'Original', content: '', createdAt: now, @@ -113,6 +117,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'To Trash', content: '', createdAt: now, @@ -132,6 +137,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'To Delete', content: '', createdAt: now, @@ -149,6 +155,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'To Archive', content: '', createdAt: now, @@ -170,6 +177,7 @@ describe('Notes CRUD', () => { await db.insert(notes).values({ id, + userId: 1, title: 'Pin Me', content: '', createdAt: now, @@ -193,6 +201,7 @@ describe('Notes with Tags', () => { await db.insert(notes).values({ id: noteId, + userId: 1, title: 'Tagged Note', content: '', createdAt: now, @@ -200,8 +209,8 @@ describe('Notes with Tags', () => { }); // Create tags - const [tag1] = await db.insert(tags).values({ name: 'work' }).returning(); - const [tag2] = await db.insert(tags).values({ name: 'important' }).returning(); + const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning(); + const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning(); // Associate await db.insert(noteTags).values([ @@ -226,13 +235,14 @@ describe('Notes with Tags', () => { await db.insert(notes).values({ id: noteId, + userId: 1, title: '', content: '', createdAt: now, updatedAt: now }); - const [tag] = await db.insert(tags).values({ name: 'temp' }).returning(); + const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning(); await db.insert(noteTags).values({ noteId, tagId: tag.id }); // Delete note_tags first (FK cascade is disabled in test DB), then note @@ -258,6 +268,7 @@ describe('Note ordering', () => { await db.insert(notes).values([ { id: 'unpinned', + userId: 1, title: 'Unpinned', content: '', pinned: false, @@ -266,6 +277,7 @@ describe('Note ordering', () => { }, { id: 'pinned', + userId: 1, title: 'Pinned', content: '', pinned: true, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index eeb5002..484c0c5 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -29,8 +29,7 @@ export const notes = sqliteTable( id: text('id').primaryKey(), userId: integer('user_id') .references(() => users.id) - .notNull() - .default(0), + .notNull(), title: text('title').default('').notNull(), content: text('content').default('').notNull(), color: text('color').default('default').notNull(), @@ -57,8 +56,7 @@ export const tags = sqliteTable( id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id') .references(() => users.id) - .notNull() - .default(0), + .notNull(), name: text('name').notNull() }, (table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)] @@ -84,8 +82,7 @@ export const attachments = sqliteTable('attachments', { id: text('id').primaryKey(), userId: integer('user_id') .references(() => users.id) - .notNull() - .default(0), + .notNull(), noteId: text('note_id') .references(() => notes.id, { onDelete: 'cascade' }) .notNull(), @@ -104,8 +101,7 @@ export const syncLog = sqliteTable( id: integer('id').primaryKey({ autoIncrement: true }), userId: integer('user_id') .references(() => users.id) - .notNull() - .default(0), + .notNull(), noteId: text('note_id') .references(() => notes.id) .notNull(), diff --git a/src/lib/server/db/search.test.ts b/src/lib/server/db/search.test.ts index 74551d3..3d6d394 100644 --- a/src/lib/server/db/search.test.ts +++ b/src/lib/server/db/search.test.ts @@ -8,18 +8,18 @@ import type * as schema from './schema.js'; let db: BetterSQLite3Database; beforeEach(async () => { - const testDb = createTestDb(); + const testDb = createTestDb({ seedUser: true }); db = testDb.db; const now = new Date(); await db.insert(notes).values([ - { id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now }, - { id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now }, - { id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now }, - { id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now } + { id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now }, + { id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now }, + { id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now }, + { id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now } ]); - const [workTag] = await db.insert(tags).values({ name: 'work' }).returning(); + const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning(); await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id }); }); diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index 765929b..a3fccfe 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -6,7 +6,7 @@ import * as schema from './schema.js'; * Create an in-memory SQLite database for testing. * Each test gets a fresh database. */ -export function createTestDb() { +export function createTestDb({ seedUser = false }: { seedUser?: boolean } = {}) { const sqlite = new Database(':memory:'); sqlite.pragma('journal_mode = WAL'); sqlite.pragma('foreign_keys = OFF'); @@ -36,7 +36,7 @@ export function createTestDb() { CREATE TABLE notes ( id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), + user_id INTEGER NOT NULL REFERENCES users(id), title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'default', @@ -56,7 +56,7 @@ export function createTestDb() { CREATE TABLE tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), + user_id INTEGER NOT NULL REFERENCES users(id), name TEXT NOT NULL ); CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id); @@ -70,7 +70,7 @@ export function createTestDb() { CREATE TABLE attachments ( id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), + user_id INTEGER NOT NULL REFERENCES users(id), note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, filename TEXT NOT NULL, mime_type TEXT NOT NULL, @@ -81,7 +81,7 @@ export function createTestDb() { CREATE TABLE sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), + user_id INTEGER NOT NULL REFERENCES users(id), note_id TEXT NOT NULL REFERENCES notes(id), operation TEXT NOT NULL, timestamp INTEGER NOT NULL, @@ -99,6 +99,10 @@ export function createTestDb() { CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp); `); + if (seedUser) { + sqlite.prepare(`INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)`).run(1, 'test@test.com', 0); + } + const db = drizzle(sqlite, { schema }); return { db, sqlite }; } diff --git a/src/lib/server/tags.ts b/src/lib/server/tags.ts index f193334..199c7ae 100644 --- a/src/lib/server/tags.ts +++ b/src/lib/server/tags.ts @@ -1,6 +1,6 @@ import { db } from './db/index.js'; import { noteTags, tags } from './db/schema.js'; -import { eq, inArray } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; /** * Batch fetch tags for multiple notes in a single query. @@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map { * Sync tags for a note: removes old associations, upserts tags, creates new associations. * Should be called within a transaction for atomicity. */ -export function syncNoteTags(noteId: string, tagNames: string[]) { +export function syncNoteTags(noteId: string, tagNames: string[], userId: number) { db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run(); for (const name of tagNames) { - let tagRow = db.select().from(tags).where(eq(tags.name, name)).get(); + let tagRow = db + .select() + .from(tags) + .where(and(eq(tags.name, name), eq(tags.userId, userId))) + .get(); if (!tagRow) { - const result = db.insert(tags).values({ name }).returning().all(); + const result = db.insert(tags).values({ name, userId }).returning().all(); tagRow = result[0]; } db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run(); diff --git a/src/lib/sync/server.ts b/src/lib/sync/server.ts index 479bb91..9bb70e9 100644 --- a/src/lib/sync/server.ts +++ b/src/lib/sync/server.ts @@ -81,7 +81,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number): const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get(); if (note) { const content = `${note.title} ${note.content}`; - syncNoteTags(noteId, extractTags(content)); + syncNoteTags(noteId, extractTags(content), userId); } } } diff --git a/src/routes/api/notes/+server.ts b/src/routes/api/notes/+server.ts index c02ebad..11016c6 100644 --- a/src/routes/api/notes/+server.ts +++ b/src/routes/api/notes/+server.ts @@ -73,7 +73,7 @@ export const POST: RequestHandler = async ({ request, ...event }) => { db.transaction((tx) => { tx.insert(notes).values(newNote).run(); }); - syncNoteTags(id, extractedTags); + syncNoteTags(id, extractedTags, userId); return json({ ...newNote, tags: extractedTags }, { status: 201 }); }; diff --git a/src/routes/api/notes/[id]/+server.ts b/src/routes/api/notes/[id]/+server.ts index facd0a5..5d8e693 100644 --- a/src/routes/api/notes/[id]/+server.ts +++ b/src/routes/api/notes/[id]/+server.ts @@ -68,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => { if (body.title !== undefined || body.content !== undefined) { const content = `${updated!.title} ${updated!.content}`; const extractedTags = extractTags(content); - syncNoteTags(params.id, extractedTags); + syncNoteTags(params.id, extractedTags, userId); } const tagMap = fetchTagsForNotes([params.id]); diff --git a/src/routes/api/notes/[id]/attachments/+server.ts b/src/routes/api/notes/[id]/attachments/+server.ts index 0a3c695..cc7ef7c 100644 --- a/src/routes/api/notes/[id]/attachments/+server.ts +++ b/src/routes/api/notes/[id]/attachments/+server.ts @@ -25,7 +25,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => { const attachmentId = url.searchParams.get('attachmentId'); if (attachmentId) { - const attachment = await getAttachment(attachmentId); + const attachment = await getAttachment(attachmentId, params.id); if (!attachment) throw error(404, 'Attachment not found'); const wantThumb = url.searchParams.get('thumb') === '1'; @@ -81,7 +81,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event }) const body = await request.json(); if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required'); - const updated = await updateAttachment(attachmentId, { featured: body.featured }); + const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured }); if (!updated) throw error(404, 'Attachment not found'); return json(updated); @@ -94,6 +94,6 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => { const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); - await deleteAttachment(attachmentId); + await deleteAttachment(attachmentId, params.id); return json({ success: true }); }; diff --git a/src/routes/api/notes/reorder/+server.ts b/src/routes/api/notes/reorder/+server.ts index 94550a2..a7953cc 100644 --- a/src/routes/api/notes/reorder/+server.ts +++ b/src/routes/api/notes/reorder/+server.ts @@ -2,9 +2,11 @@ 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 } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; +import { getUserId } from '$lib/server/api-utils.js'; -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); const body = await request.json(); const orders: { id: string; sortOrder: number }[] = body.orders; @@ -14,7 +16,10 @@ export const POST: RequestHandler = async ({ request }) => { db.transaction((tx) => { for (const { id, sortOrder } of orders) { - tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run(); + tx.update(notes) + .set({ sortOrder }) + .where(and(eq(notes.id, id), eq(notes.userId, userId))) + .run(); } });