diff --git a/drizzle/0002_deep_black_widow.sql b/drizzle/0002_deep_black_widow.sql new file mode 100644 index 0000000..b9036ca --- /dev/null +++ b/drizzle/0002_deep_black_widow.sql @@ -0,0 +1,114 @@ +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer NOT NULL, + `name` text NOT NULL, + `key_hash` text NOT NULL, + `key_prefix` text NOT NULL, + `created_at` integer NOT NULL, + `last_used_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE TABLE `note_collaborators` ( + `note_id` text NOT NULL, + `user_id` integer NOT NULL, + `added_by` integer NOT NULL, + `added_at` integer NOT NULL, + FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`added_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `note_collaborators_unique` ON `note_collaborators` (`note_id`,`user_id`);--> statement-breakpoint +CREATE INDEX `note_collaborators_user_id_idx` ON `note_collaborators` (`user_id`);--> statement-breakpoint +CREATE TABLE `note_user_state` ( + `note_id` text NOT NULL, + `user_id` integer NOT NULL, + `pinned` integer DEFAULT false NOT NULL, + `archived` integer DEFAULT false NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `note_user_state_unique` ON `note_user_state` (`note_id`,`user_id`);--> statement-breakpoint +CREATE TABLE `user_preferences` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer NOT NULL, + `key` text NOT NULL, + `value` text NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +CREATE UNIQUE INDEX `user_preferences_user_key_unique` ON `user_preferences` (`user_id`,`key`);--> statement-breakpoint +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_attachments` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer DEFAULT 0 NOT NULL, + `note_id` text NOT NULL, + `filename` text NOT NULL, + `mime_type` text NOT NULL, + `size` integer NOT NULL, + `path` text NOT NULL, + `thumbnail_path` text, + `featured` integer DEFAULT false NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_attachments`("id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at") SELECT "id", "user_id", "note_id", "filename", "mime_type", "size", "path", "thumbnail_path", "featured", "created_at" FROM `attachments`;--> statement-breakpoint +DROP TABLE `attachments`;--> statement-breakpoint +ALTER TABLE `__new_attachments` RENAME TO `attachments`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE TABLE `__new_notes` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` integer DEFAULT 0 NOT NULL, + `title` text DEFAULT '' NOT NULL, + `content` text DEFAULT '' NOT NULL, + `color` text DEFAULT 'default' NOT NULL, + `pinned` integer DEFAULT false NOT NULL, + `archived` integer DEFAULT false NOT NULL, + `trashed` integer DEFAULT false NOT NULL, + `trashed_at` integer, + `checklist_mode` integer DEFAULT false NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `version` integer DEFAULT 1 NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_notes`("id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version") SELECT "id", "user_id", "title", "content", "color", "pinned", "archived", "trashed", "trashed_at", "checklist_mode", "sort_order", "created_at", "updated_at", "version" FROM `notes`;--> statement-breakpoint +DROP TABLE `notes`;--> statement-breakpoint +ALTER TABLE `__new_notes` RENAME TO `notes`;--> statement-breakpoint +CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint +CREATE INDEX `notes_trashed_archived_idx` ON `notes` (`trashed`,`archived`);--> statement-breakpoint +CREATE INDEX `notes_updated_at_idx` ON `notes` (`updated_at`);--> statement-breakpoint +CREATE TABLE `__new_sync_log` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer DEFAULT 0 NOT NULL, + `note_id` text NOT NULL, + `operation` text NOT NULL, + `timestamp` integer NOT NULL, + `client_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action, + FOREIGN KEY (`note_id`) REFERENCES `notes`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_sync_log`("id", "user_id", "note_id", "operation", "timestamp", "client_id") SELECT "id", "user_id", "note_id", "operation", "timestamp", "client_id" FROM `sync_log`;--> statement-breakpoint +DROP TABLE `sync_log`;--> statement-breakpoint +ALTER TABLE `__new_sync_log` RENAME TO `sync_log`;--> statement-breakpoint +CREATE INDEX `sync_log_timestamp_idx` ON `sync_log` (`timestamp`);--> statement-breakpoint +CREATE TABLE `__new_tags` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `user_id` integer DEFAULT 0 NOT NULL, + `name` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_tags`("id", "user_id", "name") SELECT "id", "user_id", "name" FROM `tags`;--> statement-breakpoint +DROP TABLE `tags`;--> statement-breakpoint +ALTER TABLE `__new_tags` RENAME TO `tags`;--> statement-breakpoint +CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..5fcd642 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1007 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "273394b1-47d1-4da6-b1d3-c3d8fbefe7f9", + "prevId": "9eb400be-28ff-4b92-be35-1cd0b344f2e3", + "tables": { + "api_keys": { + "name": "api_keys", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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, + "default": 0 + }, + "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_collaborators": { + "name": "note_collaborators", + "columns": { + "note_id": { + "name": "note_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_by": { + "name": "added_by", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "added_at": { + "name": "added_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "note_collaborators_unique": { + "name": "note_collaborators_unique", + "columns": [ + "note_id", + "user_id" + ], + "isUnique": true + }, + "note_collaborators_user_id_idx": { + "name": "note_collaborators_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "note_collaborators_note_id_notes_id_fk": { + "name": "note_collaborators_note_id_notes_id_fk", + "tableFrom": "note_collaborators", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "note_collaborators_user_id_users_id_fk": { + "name": "note_collaborators_user_id_users_id_fk", + "tableFrom": "note_collaborators", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "note_collaborators_added_by_users_id_fk": { + "name": "note_collaborators_added_by_users_id_fk", + "tableFrom": "note_collaborators", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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": {} + }, + "note_user_state": { + "name": "note_user_state", + "columns": { + "note_id": { + "name": "note_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "note_user_state_unique": { + "name": "note_user_state_unique", + "columns": [ + "note_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "note_user_state_note_id_notes_id_fk": { + "name": "note_user_state_note_id_notes_id_fk", + "tableFrom": "note_user_state", + "tableTo": "notes", + "columnsFrom": [ + "note_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "note_user_state_user_id_users_id_fk": { + "name": "note_user_state_user_id_users_id_fk", + "tableFrom": "note_user_state", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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, + "default": 0 + }, + "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, + "default": 0 + }, + "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, + "default": 0 + }, + "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": {} + }, + "user_preferences": { + "name": "user_preferences", + "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 + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_preferences_user_key_unique": { + "name": "user_preferences_user_key_unique", + "columns": [ + "user_id", + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "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 a50a8f1..eafb5d2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1773016788899, "tag": "0001_strange_lord_hawal", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1773150888301, + "tag": "0002_deep_black_widow", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/lib/components/NoteEditor.svelte b/src/lib/components/NoteEditor.svelte index fb20161..7a92348 100644 --- a/src/lib/components/NoteEditor.svelte +++ b/src/lib/components/NoteEditor.svelte @@ -8,6 +8,7 @@ import { updateNote, createNote } from '$lib/stores/notes.js'; import { notes } from '$lib/stores/notes.js'; import { NOTE_COLORS } from '$lib/utils/colors.js'; + import { getPreferences } from '$lib/stores/preferences.svelte.js'; import type { Editor } from '@tiptap/core'; import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js'; import Palette from 'lucide-svelte/icons/palette'; @@ -26,19 +27,20 @@ } const { note, isNew = false, onClose }: Props = $props(); + const prefs = getPreferences(); // svelte-ignore state_referenced_locally let title = $state(note?.title ?? ''); // svelte-ignore state_referenced_locally let content = $state(note?.content ?? ''); // svelte-ignore state_referenced_locally - let color = $state(note?.color ?? 'default'); + let color = $state(note?.color ?? prefs.defaultNoteColor); // svelte-ignore state_referenced_locally let checklistMode = $state(note?.checklistMode ?? false); let showColorPicker = $state(false); let showImageUpload = $state(false); // svelte-ignore state_referenced_locally - let rawMarkdownMode = $state(note?.checklistMode ?? false); + let rawMarkdownMode = $state(note ? (note.checklistMode ?? false) : prefs.defaultNoteMode === 'markdown'); let textareaEl: HTMLTextAreaElement | undefined = $state(); let tiptapEditor: Editor | undefined = $state(); let editorTick = $state(0); diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 53353e4..e055336 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -115,6 +115,14 @@ sqlite.exec(` success INTEGER NOT NULL, timestamp INTEGER NOT NULL ); + + CREATE TABLE IF NOT EXISTS user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); `); // Migration: detect old schema (no email column on users) and add columns @@ -186,6 +194,7 @@ sqlite.exec(` 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 UNIQUE INDEX IF NOT EXISTS user_preferences_user_key_unique ON user_preferences(user_id, key); CREATE TABLE IF NOT EXISTS api_keys ( id TEXT PRIMARY KEY, diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 4dd8c1f..d3c80f8 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -175,3 +175,17 @@ export const loginAttempts = sqliteTable( }, (table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)] ); + +export const userPreferences = sqliteTable( + 'user_preferences', + { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id') + .references(() => users.id) + .notNull(), + key: text('key').notNull(), + value: text('value').notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull() + }, + (table) => [uniqueIndex('user_preferences_user_key_unique').on(table.userId, table.key)] +); diff --git a/src/lib/server/db/test-helpers.ts b/src/lib/server/db/test-helpers.ts index ca4d6a7..68d42eb 100644 --- a/src/lib/server/db/test-helpers.ts +++ b/src/lib/server/db/test-helpers.ts @@ -127,6 +127,15 @@ export function createTestDb(options?: { seedUser?: boolean }) { created_at INTEGER NOT NULL, last_used_at INTEGER ); + + CREATE TABLE user_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX user_preferences_user_key_unique ON user_preferences(user_id, key); `); const db = drizzle(sqlite, { schema }); diff --git a/src/lib/server/preferences.test.ts b/src/lib/server/preferences.test.ts new file mode 100644 index 0000000..844385f --- /dev/null +++ b/src/lib/server/preferences.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createTestDb } from './db/test-helpers.js'; +import type { Db } from './db/index.js'; +import { getPreferences, upsertPreferences } from './preferences.js'; + +let db: Db; +const USER_ID = 1; + +beforeEach(() => { + const testDb = createTestDb({ seedUser: true }); + db = testDb.db; +}); + +describe('getPreferences', () => { + it('should return empty object for user with no preferences', () => { + const prefs = getPreferences(db, USER_ID); + expect(prefs).toEqual({}); + }); +}); + +describe('upsertPreferences', () => { + it('should insert new preferences', () => { + const result = upsertPreferences(db, USER_ID, { + defaultNoteMode: 'markdown', + hideFooter: 'true' + }); + expect(result).toEqual({ + defaultNoteMode: 'markdown', + hideFooter: 'true' + }); + }); + + it('should update existing preferences', () => { + upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' }); + const result = upsertPreferences(db, USER_ID, { defaultNoteMode: 'richtext' }); + expect(result.defaultNoteMode).toBe('richtext'); + }); + + it('should handle partial updates without affecting other keys', () => { + upsertPreferences(db, USER_ID, { + defaultNoteMode: 'markdown', + hideFooter: 'true' + }); + const result = upsertPreferences(db, USER_ID, { hideFooter: 'false' }); + expect(result).toEqual({ + defaultNoteMode: 'markdown', + hideFooter: 'false' + }); + }); + + it('should keep preferences isolated between users', () => { + // Create a second user + db.run( + /* sql */ `INSERT INTO users (email, display_name, role, auth_provider, created_at) VALUES ('user2@test.com', 'User 2', 'user', 'password', ${Date.now()})` + ); + upsertPreferences(db, USER_ID, { defaultNoteMode: 'markdown' }); + upsertPreferences(db, 2, { defaultNoteMode: 'richtext' }); + + expect(getPreferences(db, USER_ID).defaultNoteMode).toBe('markdown'); + expect(getPreferences(db, 2).defaultNoteMode).toBe('richtext'); + }); +}); diff --git a/src/lib/server/preferences.ts b/src/lib/server/preferences.ts new file mode 100644 index 0000000..2ed9040 --- /dev/null +++ b/src/lib/server/preferences.ts @@ -0,0 +1,42 @@ +import type { Db } from './db/index.js'; +import { userPreferences } from './db/schema.js'; +import { eq } from 'drizzle-orm'; + +/** + * Get all preferences for a user as a key-value record. + */ +export function getPreferences(db: Db, userId: number): Record { + const rows = db + .select({ key: userPreferences.key, value: userPreferences.value }) + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .all(); + + const result: Record = {}; + for (const row of rows) { + result[row.key] = row.value; + } + return result; +} + +/** + * Upsert multiple preferences for a user. + * Uses INSERT OR REPLACE with the unique (user_id, key) index. + */ +export function upsertPreferences( + db: Db, + userId: number, + prefs: Record +): Record { + const now = new Date(); + for (const [key, value] of Object.entries(prefs)) { + db.insert(userPreferences) + .values({ userId, key, value, updatedAt: now }) + .onConflictDoUpdate({ + target: [userPreferences.userId, userPreferences.key], + set: { value, updatedAt: now } + }) + .run(); + } + return getPreferences(db, userId); +} diff --git a/src/lib/stores/preferences.svelte.ts b/src/lib/stores/preferences.svelte.ts new file mode 100644 index 0000000..1c3a448 --- /dev/null +++ b/src/lib/stores/preferences.svelte.ts @@ -0,0 +1,114 @@ +import { browser } from '$app/environment'; +import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js'; +import type { UserPreferences } from '$lib/types/preferences.js'; + +const STORAGE_KEY = 'crumbs-preferences'; + +let preferences = $state({ ...DEFAULT_PREFERENCES }); + +function loadFromLocalStorage(): UserPreferences { + if (!browser) return { ...DEFAULT_PREFERENCES }; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return { ...DEFAULT_PREFERENCES, ...parsed }; + } + } catch { + // Corrupted localStorage — use defaults + } + return { ...DEFAULT_PREFERENCES }; +} + +function saveToLocalStorage(prefs: UserPreferences) { + if (!browser) return; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // localStorage full or unavailable + } +} + +/** Convert server key-value record to typed UserPreferences */ +function fromServerRecord(record: Record): Partial { + const result: Partial = {}; + for (const [key, value] of Object.entries(record)) { + if (key in DEFAULT_PREFERENCES) { + const k = key as keyof UserPreferences; + if (BOOLEAN_PREF_KEYS.has(k)) { + (result as Record)[k] = value === 'true'; + } else { + (result as Record)[k] = value; + } + } + } + return result; +} + +/** Convert UserPreferences to server key-value record */ +function toServerRecord(prefs: Partial): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(prefs)) { + result[key] = String(value); + } + return result; +} + +/** + * Initialize preferences — load from localStorage, then sync from server. + */ +export function initPreferences() { + preferences = loadFromLocalStorage(); + syncPreferencesFromServer(); +} + +/** + * Get the reactive preferences object. + */ +export function getPreferences(): UserPreferences { + return preferences; +} + +/** + * Update a single preference. Saves to localStorage and pushes to server. + */ +export function updatePreference( + key: K, + value: UserPreferences[K] +) { + preferences = { ...preferences, [key]: value }; + saveToLocalStorage(preferences); + pushToServer({ [key]: value }); +} + +/** + * Pull preferences from server and merge into local state. + */ +export async function syncPreferencesFromServer() { + if (!browser) return; + try { + const res = await fetch('/api/preferences'); + if (res.ok) { + const serverPrefs: Record = await res.json(); + const parsed = fromServerRecord(serverPrefs); + preferences = { ...DEFAULT_PREFERENCES, ...parsed }; + saveToLocalStorage(preferences); + } + } catch { + // Offline — local state stands + } +} + +/** Push preference changes to server */ +async function pushToServer(partial: Partial) { + if (!browser) return; + try { + await fetch('/api/preferences', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(toServerRecord(partial)) + }); + } catch { + // Offline — localStorage already updated + } +} diff --git a/src/lib/stores/preferences.test.ts b/src/lib/stores/preferences.test.ts new file mode 100644 index 0000000..8ec1cec --- /dev/null +++ b/src/lib/stores/preferences.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from '$lib/types/preferences.js'; + +describe('preferences defaults', () => { + it('should have correct default values', () => { + expect(DEFAULT_PREFERENCES).toEqual({ + defaultNoteMode: 'richtext', + defaultNoteColor: 'default', + hideFooter: false, + sidebarDefaultState: 'open' + }); + }); + + it('should identify boolean preference keys', () => { + expect(BOOLEAN_PREF_KEYS.has('hideFooter')).toBe(true); + expect(BOOLEAN_PREF_KEYS.has('defaultNoteMode')).toBe(false); + expect(BOOLEAN_PREF_KEYS.has('defaultNoteColor')).toBe(false); + expect(BOOLEAN_PREF_KEYS.has('sidebarDefaultState')).toBe(false); + }); +}); diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 47af735..494abe4 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -97,3 +97,6 @@ export type NoteColor = export type NoteFilter = 'all' | 'archived' | 'trashed'; export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error'; + +export type { UserPreferences } from './preferences.js'; +export { DEFAULT_PREFERENCES, BOOLEAN_PREF_KEYS } from './preferences.js'; diff --git a/src/lib/types/preferences.ts b/src/lib/types/preferences.ts new file mode 100644 index 0000000..c05b3b0 --- /dev/null +++ b/src/lib/types/preferences.ts @@ -0,0 +1,19 @@ +import type { NoteColor } from './index.js'; + +export interface UserPreferences { + defaultNoteMode: 'richtext' | 'markdown'; + defaultNoteColor: NoteColor; + hideFooter: boolean; + sidebarDefaultState: 'open' | 'collapsed'; +} + +export const DEFAULT_PREFERENCES: UserPreferences = { + defaultNoteMode: 'richtext', + defaultNoteColor: 'default', + hideFooter: false, + sidebarDefaultState: 'open' +}; + +export const BOOLEAN_PREF_KEYS: ReadonlySet = new Set([ + 'hideFooter' +]); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index ea99b7b..9f4a8f3 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -5,13 +5,21 @@ import { loadNotes } from '$lib/stores/notes.js'; import { startSync, stopSync } from '$lib/sync/client.js'; import { initDb } from '$lib/sync/idb.js'; + import { initPreferences, getPreferences } from '$lib/stores/preferences.svelte.js'; import { onMount, onDestroy } from 'svelte'; let { data, children } = $props(); let sidebarOpen = $state(false); + const prefs = $derived(getPreferences()); onMount(() => { - sidebarOpen = window.matchMedia('(min-width: 1024px)').matches; + initPreferences(); + const prefState = getPreferences(); + if (prefState.sidebarDefaultState === 'collapsed') { + sidebarOpen = false; + } else { + sidebarOpen = window.matchMedia('(min-width: 1024px)').matches; + } if (data.user) { initDb(data.user.id); } @@ -34,9 +42,11 @@ -
- Crumbs by Bretzel — made with 🥨 in Strasbourg -
+ {#if !prefs.hideFooter} +
+ Crumbs by Bretzel — made with 🥨 in Strasbourg +
+ {/if} diff --git a/src/routes/(app)/settings/+layout.svelte b/src/routes/(app)/settings/+layout.svelte index 51174eb..cd933fa 100644 --- a/src/routes/(app)/settings/+layout.svelte +++ b/src/routes/(app)/settings/+layout.svelte @@ -1,6 +1,6 @@ + +
+

Preferences

+ + +
+ Default note mode +
+ + +
+
+ + +
+ Default note color + updatePreference('defaultNoteColor', color)} + /> +
+ + +
+ updatePreference('hideFooter', !prefs.hideFooter)} + class="h-4 w-4 rounded-sm" + data-testid="pref-hide-footer" + /> + +
+ + +
+ updatePreference('sidebarDefaultState', prefs.sidebarDefaultState === 'collapsed' ? 'open' : 'collapsed')} + class="h-4 w-4 rounded-sm" + data-testid="pref-sidebar-default" + /> + +
+
diff --git a/src/routes/api/preferences/+server.ts b/src/routes/api/preferences/+server.ts new file mode 100644 index 0000000..9ba13e9 --- /dev/null +++ b/src/routes/api/preferences/+server.ts @@ -0,0 +1,18 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; +import { getUserId } from '$lib/server/api-utils.js'; +import { getPreferences, upsertPreferences } from '$lib/server/preferences.js'; + +export const GET: RequestHandler = async (event) => { + const userId = getUserId(event); + const prefs = getPreferences(db, userId); + return json(prefs); +}; + +export const PUT: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); + const body: Record = await request.json(); + const updated = upsertPreferences(db, userId, body); + return json(updated); +}; diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts index ad4afbf..103c094 100644 --- a/tests/e2e/settings.spec.ts +++ b/tests/e2e/settings.spec.ts @@ -1,5 +1,73 @@ import { test, expect } from './helpers/fixtures.js'; +test.describe('Settings — Preferences', () => { + test('Scenario: Preferences tab is visible in settings nav', async ({ + authenticatedPage: page + }) => { + // When the user navigates to settings + await page.goto('/settings'); + + // Then the Preferences nav link is visible + await expect(page.getByTestId('settings-nav-preferences')).toBeVisible(); + }); + + test('Scenario: Default note mode change applies to new notes', async ({ + authenticatedPage: page + }) => { + // Given the user sets default note mode to Markdown + await page.goto('/settings/preferences'); + const putResponse = page.waitForResponse((res) => res.url().includes('/api/preferences') && res.request().method() === 'PUT'); + await page.getByTestId('pref-mode-markdown').click(); + await putResponse; + + // When the user creates a new note + await page.goto('/'); + await page.getByTestId('new-note-btn').click(); + + // Then the note editor opens in markdown mode (textarea visible) + await expect(page.getByTestId('note-content-input')).toBeVisible(); + }); + + test('Scenario: Footer toggle hides and shows footer', async ({ + authenticatedPage: page + }) => { + // Given the footer is visible + await page.goto('/'); + await expect(page.getByTestId('app-footer')).toBeVisible(); + + // When the user enables the hide footer preference + await page.goto('/settings/preferences'); + await page.getByTestId('pref-hide-footer').check(); + + // Then the footer is hidden + await page.goto('/'); + await expect(page.getByTestId('app-footer')).not.toBeVisible(); + + // When the user disables the hide footer preference + await page.goto('/settings/preferences'); + await page.getByTestId('pref-hide-footer').uncheck(); + + // Then the footer is visible again + await page.goto('/'); + await expect(page.getByTestId('app-footer')).toBeVisible(); + }); + + test('Scenario: Preferences persist across page reload', async ({ + authenticatedPage: page + }) => { + // Given the user changes default note mode to Markdown + await page.goto('/settings/preferences'); + await page.getByTestId('pref-mode-markdown').click(); + + // When the page is reloaded + await page.reload(); + + // Then the Markdown button is still selected (has primary styling) + const mdBtn = page.getByTestId('pref-mode-markdown'); + await expect(mdBtn).toHaveClass(/font-medium/); + }); +}); + test.describe('Settings — API Key Management', () => { test('Scenario: Created API key appears in the keys list', async ({ authenticatedPage: page