Merge pull request #71 from kitpdr/refacto/convex-backend-migration
refactor: migrate from Turso/Drizzle to Convex backend
68 files · +2915 -4512
M .github/workflows/ci.yml +2 -0
| @@ -23,6 +23,8 @@ env: | |||
| 23 | 23 | TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA' | |
| 24 | 24 | PUBLIC_TURNSTILE_SITE_KEY: '1x00000000000000000000AA' | |
| 25 | 25 | PUBLIC_DEFAULT_CHAT_ROOM_ID: '00000000-0000-0000-0000-000000000001' | |
| 26 | + | CONVEX_API_SECRET: 'dummy-convex-secret' | |
| 27 | + | PUBLIC_CONVEX_URL: 'https://dummy.convex.cloud' | |
| 26 | 28 | ||
| 27 | 29 | jobs: | |
| 28 | 30 | check: | |
M .gitignore +4 -1
| @@ -24,4 +24,7 @@ vite.config.ts.timestamp-* | |||
| 24 | 24 | ||
| 25 | 25 | docs/ | |
| 26 | 26 | ||
| 27 | - | .serena/⏎̸ | |
| 27 | + | .serena/ | |
| 28 | + | ||
| 29 | + | # Migration files (Turso to Convex) | |
| 30 | + | migration/⏎̸ | |
M .prettierignore +4 -0
| @@ -2,3 +2,7 @@ | |||
| 2 | 2 | package-lock.json | |
| 3 | 3 | pnpm-lock.yaml | |
| 4 | 4 | yarn.lock | |
| 5 | + | ||
| 6 | + | # Auto-generated files | |
| 7 | + | src/convex/_generated/ | |
| 8 | + | .claude/ | |
M README.md +20 -14
| @@ -7,6 +7,7 @@ Ce projet _sandbox_, à but non lucratif, recrée l'ambiance des salons de discu | |||
| 7 | 7 | - SvelteKit (version 5) | |
| 8 | 8 | - Node.js | |
| 9 | 9 | - TypeScript | |
| 10 | + | - Convex (base de données temps réel) | |
| 10 | 11 | - XP.css | |
| 11 | 12 | - Tailwind CSS | |
| 12 | 13 | ||
| @@ -48,33 +49,38 @@ Pour contribuer : | |||
| 48 | 49 | ||
| 49 | 50 | ## Base de données | |
| 50 | 51 | ||
| 51 | - | Si vous souhaitez pouvoir tester le chat en local, vous devez configurer une base de données. Le projet utilise Drizzle ORM et Turso https://turso.tech/ comme base de données. Je vous invite à en créer une pour vos tests en local et à regarder le fichier `.env.example`. | |
| 52 | + | Le projet utilise [Convex](https://convex.dev/) comme base de données temps réel. Convex gère automatiquement les mises à jour en temps réel des messages et de la liste des utilisateurs. | |
| 52 | 53 | ||
| 53 | - | Pour générer les schémas de la base de données, exécutez la commande suivante : | |
| 54 | + | ### Configuration locale | |
| 54 | 55 | ||
| 55 | - | ```bash | |
| 56 | - | npm run db:generate | |
| 57 | - | # ou | |
| 58 | - | pnpm db:generate | |
| 59 | - | ``` | |
| 56 | + | 1. Créez un compte sur [Convex](https://convex.dev/) et un nouveau projet | |
| 57 | + | 2. Copiez `.env.example` vers `.env.local` et remplissez les variables : | |
| 58 | + | - `PUBLIC_CONVEX_URL` : URL de votre déploiement Convex | |
| 59 | + | - `CONVEX_API_SECRET` : Clé secrète pour les appels serveur | |
| 60 | + | ||
| 61 | + | ### Lancer Convex en développement | |
| 60 | 62 | ||
| 61 | - | Pour pousser les schémas à la base de données, exécutez la commande suivante : | |
| 63 | + | Dans un terminal séparé, lancez le serveur Convex : | |
| 62 | 64 | ||
| 63 | 65 | ```bash | |
| 64 | - | npm run db:push | |
| 65 | - | # ou | |
| 66 | - | pnpm db:push | |
| 66 | + | npx convex dev | |
| 67 | 67 | ``` | |
| 68 | 68 | ||
| 69 | - | Vous pouvez avoir un aperçu du contenu de la base en utilisant : | |
| 69 | + | Cela synchronise automatiquement vos fonctions backend avec Convex et affiche les logs en temps réel. | |
| 70 | + | ||
| 71 | + | ### Dashboard Convex | |
| 72 | + | ||
| 73 | + | Vous pouvez visualiser et gérer vos données via le dashboard Convex : | |
| 70 | 74 | ||
| 71 | 75 | ```bash | |
| 72 | - | npx drizzle-kit studio | |
| 76 | + | npx convex dashboard | |
| 73 | 77 | ``` | |
| 74 | 78 | ||
| 75 | 79 | ## Documentation et Ressources | |
| 76 | 80 | ||
| 77 | - | Si vous n'avez jamais utilisé Svelte/Kit, référez-vous aux documentations officielles : | |
| 81 | + | Si vous n'avez jamais utilisé Svelte/Kit ou Convex, référez-vous aux documentations officielles : | |
| 78 | 82 | ||
| 79 | 83 | - [Documentation de Svelte](https://svelte.dev/docs/) | |
| 80 | 84 | - [Documentation de SvelteKit](https://svelte.dev/docs/kit/@sveltejs-kit) | |
| 85 | + | - [Documentation de Convex](https://docs.convex.dev/) | |
| 86 | + | - [Convex + Svelte](https://docs.convex.dev/client/svelte) | |
A convex.json +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | { | |
| 2 | + | "functions": "src/convex/" | |
| 3 | + | } | |
D drizzle.config.ts +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | import 'dotenv/config'; | |
| 2 | - | import { defineConfig } from 'drizzle-kit'; | |
| 3 | - | ||
| 4 | - | export default defineConfig({ | |
| 5 | - | out: './drizzle', | |
| 6 | - | schema: './src/lib/db/schema.ts', | |
| 7 | - | dialect: 'turso', | |
| 8 | - | dbCredentials: { | |
| 9 | - | url: process.env.TURSO_DATABASE_URL as string, | |
| 10 | - | authToken: process.env.TURSO_AUTH_TOKEN as string | |
| 11 | - | } | |
| 12 | - | }); | |
D drizzle/0000_shallow_ghost_rider.sql +0 -33
| @@ -1,33 +0,0 @@ | |||
| 1 | - | CREATE TABLE `chat_rooms` ( | |
| 2 | - | `id` text PRIMARY KEY NOT NULL, | |
| 3 | - | `name` text, | |
| 4 | - | `type` text DEFAULT 'direct' NOT NULL, | |
| 5 | - | `created_at` integer NOT NULL | |
| 6 | - | ); | |
| 7 | - | --> statement-breakpoint | |
| 8 | - | CREATE TABLE `messages` ( | |
| 9 | - | `id` text PRIMARY KEY NOT NULL, | |
| 10 | - | `chat_room_id` text NOT NULL, | |
| 11 | - | `sender_id` text NOT NULL, | |
| 12 | - | `content` text NOT NULL, | |
| 13 | - | `type` text DEFAULT 'chat' NOT NULL, | |
| 14 | - | `timestamp` integer NOT NULL, | |
| 15 | - | FOREIGN KEY (`chat_room_id`) REFERENCES `chat_rooms`(`id`) ON UPDATE no action ON DELETE no action, | |
| 16 | - | FOREIGN KEY (`sender_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action | |
| 17 | - | ); | |
| 18 | - | --> statement-breakpoint | |
| 19 | - | CREATE TABLE `users` ( | |
| 20 | - | `id` text PRIMARY KEY NOT NULL, | |
| 21 | - | `email` text NOT NULL, | |
| 22 | - | `password` text NOT NULL, | |
| 23 | - | `nickname` text NOT NULL, | |
| 24 | - | `status` text DEFAULT 'offline', | |
| 25 | - | `avatar_url` text, | |
| 26 | - | `created_at` integer NOT NULL, | |
| 27 | - | `last_seen` integer | |
| 28 | - | ); | |
| 29 | - | --> statement-breakpoint | |
| 30 | - | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint | |
| 31 | - | CREATE VIEW `chat_room_view` AS select "id", "name", "type", "created_at" from "chat_rooms";--> statement-breakpoint | |
| 32 | - | CREATE VIEW `message_view` AS select "messages"."id", "messages"."content", "messages"."type", "messages"."timestamp", "users"."nickname", "chat_rooms"."name" from "messages" left join "users" on "messages"."sender_id" = "users"."id" left join "chat_rooms" on "messages"."chat_room_id" = "chat_rooms"."id";--> statement-breakpoint | |
| 33 | - | CREATE VIEW `user_view` AS select "id", "nickname", "status", "avatar_url", "last_seen" from "users";⏎̸ | |
D drizzle/0001_blushing_jean_grey.sql +0 -11
| @@ -1,11 +0,0 @@ | |||
| 1 | - | CREATE TABLE `sessions` ( | |
| 2 | - | `id` text PRIMARY KEY NOT NULL, | |
| 3 | - | `user_id` text NOT NULL, | |
| 4 | - | `expires_at` integer NOT NULL, | |
| 5 | - | `created_at` integer NOT NULL, | |
| 6 | - | FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action | |
| 7 | - | ); | |
| 8 | - | --> statement-breakpoint | |
| 9 | - | DROP INDEX `users_email_unique`;--> statement-breakpoint | |
| 10 | - | ALTER TABLE `users` ALTER COLUMN "status" TO "status" text NOT NULL DEFAULT 'offline';--> statement-breakpoint | |
| 11 | - | ALTER TABLE `users` DROP COLUMN `email`;⏎̸ | |
D drizzle/meta/0000_snapshot.json +0 -332
| @@ -1,332 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "version": "6", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 5 | - | "prevId": "00000000-0000-0000-0000-000000000000", | |
| 6 | - | "tables": { | |
| 7 | - | "chat_rooms": { | |
| 8 | - | "name": "chat_rooms", | |
| 9 | - | "columns": { | |
| 10 | - | "id": { | |
| 11 | - | "name": "id", | |
| 12 | - | "type": "text", | |
| 13 | - | "primaryKey": true, | |
| 14 | - | "notNull": true, | |
| 15 | - | "autoincrement": false | |
| 16 | - | }, | |
| 17 | - | "name": { | |
| 18 | - | "name": "name", | |
| 19 | - | "type": "text", | |
| 20 | - | "primaryKey": false, | |
| 21 | - | "notNull": false, | |
| 22 | - | "autoincrement": false | |
| 23 | - | }, | |
| 24 | - | "type": { | |
| 25 | - | "name": "type", | |
| 26 | - | "type": "text", | |
| 27 | - | "primaryKey": false, | |
| 28 | - | "notNull": true, | |
| 29 | - | "autoincrement": false, | |
| 30 | - | "default": "'direct'" | |
| 31 | - | }, | |
| 32 | - | "created_at": { | |
| 33 | - | "name": "created_at", | |
| 34 | - | "type": "integer", | |
| 35 | - | "primaryKey": false, | |
| 36 | - | "notNull": true, | |
| 37 | - | "autoincrement": false | |
| 38 | - | } | |
| 39 | - | }, | |
| 40 | - | "indexes": {}, | |
| 41 | - | "foreignKeys": {}, | |
| 42 | - | "compositePrimaryKeys": {}, | |
| 43 | - | "uniqueConstraints": {}, | |
| 44 | - | "checkConstraints": {} | |
| 45 | - | }, | |
| 46 | - | "messages": { | |
| 47 | - | "name": "messages", | |
| 48 | - | "columns": { | |
| 49 | - | "id": { | |
| 50 | - | "name": "id", | |
| 51 | - | "type": "text", | |
| 52 | - | "primaryKey": true, | |
| 53 | - | "notNull": true, | |
| 54 | - | "autoincrement": false | |
| 55 | - | }, | |
| 56 | - | "chat_room_id": { | |
| 57 | - | "name": "chat_room_id", | |
| 58 | - | "type": "text", | |
| 59 | - | "primaryKey": false, | |
| 60 | - | "notNull": true, | |
| 61 | - | "autoincrement": false | |
| 62 | - | }, | |
| 63 | - | "sender_id": { | |
| 64 | - | "name": "sender_id", | |
| 65 | - | "type": "text", | |
| 66 | - | "primaryKey": false, | |
| 67 | - | "notNull": true, | |
| 68 | - | "autoincrement": false | |
| 69 | - | }, | |
| 70 | - | "content": { | |
| 71 | - | "name": "content", | |
| 72 | - | "type": "text", | |
| 73 | - | "primaryKey": false, | |
| 74 | - | "notNull": true, | |
| 75 | - | "autoincrement": false | |
| 76 | - | }, | |
| 77 | - | "type": { | |
| 78 | - | "name": "type", | |
| 79 | - | "type": "text", | |
| 80 | - | "primaryKey": false, | |
| 81 | - | "notNull": true, | |
| 82 | - | "autoincrement": false, | |
| 83 | - | "default": "'chat'" | |
| 84 | - | }, | |
| 85 | - | "timestamp": { | |
| 86 | - | "name": "timestamp", | |
| 87 | - | "type": "integer", | |
| 88 | - | "primaryKey": false, | |
| 89 | - | "notNull": true, | |
| 90 | - | "autoincrement": false | |
| 91 | - | } | |
| 92 | - | }, | |
| 93 | - | "indexes": {}, | |
| 94 | - | "foreignKeys": { | |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | - | "tableFrom": "messages", | |
| 98 | - | "tableTo": "chat_rooms", | |
| 99 | - | "columnsFrom": ["chat_room_id"], | |
| 100 | - | "columnsTo": ["id"], | |
| 101 | - | "onDelete": "no action", | |
| 102 | - | "onUpdate": "no action" | |
| 103 | - | }, | |
| 104 | - | "messages_sender_id_users_id_fk": { | |
| 105 | - | "name": "messages_sender_id_users_id_fk", | |
| 106 | - | "tableFrom": "messages", | |
| 107 | - | "tableTo": "users", | |
| 108 | - | "columnsFrom": ["sender_id"], | |
| 109 | - | "columnsTo": ["id"], | |
| 110 | - | "onDelete": "no action", | |
| 111 | - | "onUpdate": "no action" | |
| 112 | - | } | |
| 113 | - | }, | |
| 114 | - | "compositePrimaryKeys": {}, | |
| 115 | - | "uniqueConstraints": {}, | |
| 116 | - | "checkConstraints": {} | |
| 117 | - | }, | |
| 118 | - | "users": { | |
| 119 | - | "name": "users", | |
| 120 | - | "columns": { | |
| 121 | - | "id": { | |
| 122 | - | "name": "id", | |
| 123 | - | "type": "text", | |
| 124 | - | "primaryKey": true, | |
| 125 | - | "notNull": true, | |
| 126 | - | "autoincrement": false | |
| 127 | - | }, | |
| 128 | - | "email": { | |
| 129 | - | "name": "email", | |
| 130 | - | "type": "text", | |
| 131 | - | "primaryKey": false, | |
| 132 | - | "notNull": true, | |
| 133 | - | "autoincrement": false | |
| 134 | - | }, | |
| 135 | - | "password": { | |
| 136 | - | "name": "password", | |
| 137 | - | "type": "text", | |
| 138 | - | "primaryKey": false, | |
| 139 | - | "notNull": true, | |
| 140 | - | "autoincrement": false | |
| 141 | - | }, | |
| 142 | - | "nickname": { | |
| 143 | - | "name": "nickname", | |
| 144 | - | "type": "text", | |
| 145 | - | "primaryKey": false, | |
| 146 | - | "notNull": true, | |
| 147 | - | "autoincrement": false | |
| 148 | - | }, | |
| 149 | - | "status": { | |
| 150 | - | "name": "status", | |
| 151 | - | "type": "text", | |
| 152 | - | "primaryKey": false, | |
| 153 | - | "notNull": false, | |
| 154 | - | "autoincrement": false, | |
| 155 | - | "default": "'offline'" | |
| 156 | - | }, | |
| 157 | - | "avatar_url": { | |
| 158 | - | "name": "avatar_url", | |
| 159 | - | "type": "text", | |
| 160 | - | "primaryKey": false, | |
| 161 | - | "notNull": false, | |
| 162 | - | "autoincrement": false | |
| 163 | - | }, | |
| 164 | - | "created_at": { | |
| 165 | - | "name": "created_at", | |
| 166 | - | "type": "integer", | |
| 167 | - | "primaryKey": false, | |
| 168 | - | "notNull": true, | |
| 169 | - | "autoincrement": false | |
| 170 | - | }, | |
| 171 | - | "last_seen": { | |
| 172 | - | "name": "last_seen", | |
| 173 | - | "type": "integer", | |
| 174 | - | "primaryKey": false, | |
| 175 | - | "notNull": false, | |
| 176 | - | "autoincrement": false | |
| 177 | - | } | |
| 178 | - | }, | |
| 179 | - | "indexes": { | |
| 180 | - | "users_email_unique": { | |
| 181 | - | "name": "users_email_unique", | |
| 182 | - | "columns": ["email"], | |
| 183 | - | "isUnique": true | |
| 184 | - | } | |
| 185 | - | }, | |
| 186 | - | "foreignKeys": {}, | |
| 187 | - | "compositePrimaryKeys": {}, | |
| 188 | - | "uniqueConstraints": {}, | |
| 189 | - | "checkConstraints": {} | |
| 190 | - | } | |
| 191 | - | }, | |
| 192 | - | "views": { | |
| 193 | - | "chat_room_view": { | |
| 194 | - | "columns": { | |
| 195 | - | "id": { | |
| 196 | - | "name": "id", | |
| 197 | - | "type": "text", | |
| 198 | - | "primaryKey": true, | |
| 199 | - | "notNull": true, | |
| 200 | - | "autoincrement": false | |
| 201 | - | }, | |
| 202 | - | "name": { | |
| 203 | - | "name": "name", | |
| 204 | - | "type": "text", | |
| 205 | - | "primaryKey": false, | |
| 206 | - | "notNull": false, | |
| 207 | - | "autoincrement": false | |
| 208 | - | }, | |
| 209 | - | "type": { | |
| 210 | - | "name": "type", | |
| 211 | - | "type": "text", | |
| 212 | - | "primaryKey": false, | |
| 213 | - | "notNull": true, | |
| 214 | - | "autoincrement": false, | |
| 215 | - | "default": "'direct'" | |
| 216 | - | }, | |
| 217 | - | "created_at": { | |
| 218 | - | "name": "created_at", | |
| 219 | - | "type": "integer", | |
| 220 | - | "primaryKey": false, | |
| 221 | - | "notNull": true, | |
| 222 | - | "autoincrement": false | |
| 223 | - | } | |
| 224 | - | }, | |
| 225 | - | "name": "chat_room_view", | |
| 226 | - | "isExisting": false, | |
| 227 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 228 | - | }, | |
| 229 | - | "message_view": { | |
| 230 | - | "columns": { | |
| 231 | - | "id": { | |
| 232 | - | "name": "id", | |
| 233 | - | "type": "text", | |
| 234 | - | "primaryKey": true, | |
| 235 | - | "notNull": true, | |
| 236 | - | "autoincrement": false | |
| 237 | - | }, | |
| 238 | - | "content": { | |
| 239 | - | "name": "content", | |
| 240 | - | "type": "text", | |
| 241 | - | "primaryKey": false, | |
| 242 | - | "notNull": true, | |
| 243 | - | "autoincrement": false | |
| 244 | - | }, | |
| 245 | - | "type": { | |
| 246 | - | "name": "type", | |
| 247 | - | "type": "text", | |
| 248 | - | "primaryKey": false, | |
| 249 | - | "notNull": true, | |
| 250 | - | "autoincrement": false, | |
| 251 | - | "default": "'chat'" | |
| 252 | - | }, | |
| 253 | - | "timestamp": { | |
| 254 | - | "name": "timestamp", | |
| 255 | - | "type": "integer", | |
| 256 | - | "primaryKey": false, | |
| 257 | - | "notNull": true, | |
| 258 | - | "autoincrement": false | |
| 259 | - | }, | |
| 260 | - | "nickname": { | |
| 261 | - | "name": "nickname", | |
| 262 | - | "type": "text", | |
| 263 | - | "primaryKey": false, | |
| 264 | - | "notNull": true, | |
| 265 | - | "autoincrement": false | |
| 266 | - | }, | |
| 267 | - | "name": { | |
| 268 | - | "name": "name", | |
| 269 | - | "type": "text", | |
| 270 | - | "primaryKey": false, | |
| 271 | - | "notNull": false, | |
| 272 | - | "autoincrement": false | |
| 273 | - | } | |
| 274 | - | }, | |
| 275 | - | "name": "message_view", | |
| 276 | - | "isExisting": false, | |
| 277 | - | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 278 | - | }, | |
| 279 | - | "user_view": { | |
| 280 | - | "columns": { | |
| 281 | - | "id": { | |
| 282 | - | "name": "id", | |
| 283 | - | "type": "text", | |
| 284 | - | "primaryKey": true, | |
| 285 | - | "notNull": true, | |
| 286 | - | "autoincrement": false | |
| 287 | - | }, | |
| 288 | - | "nickname": { | |
| 289 | - | "name": "nickname", | |
| 290 | - | "type": "text", | |
| 291 | - | "primaryKey": false, | |
| 292 | - | "notNull": true, | |
| 293 | - | "autoincrement": false | |
| 294 | - | }, | |
| 295 | - | "status": { | |
| 296 | - | "name": "status", | |
| 297 | - | "type": "text", | |
| 298 | - | "primaryKey": false, | |
| 299 | - | "notNull": false, | |
| 300 | - | "autoincrement": false, | |
| 301 | - | "default": "'offline'" | |
| 302 | - | }, | |
| 303 | - | "avatar_url": { | |
| 304 | - | "name": "avatar_url", | |
| 305 | - | "type": "text", | |
| 306 | - | "primaryKey": false, | |
| 307 | - | "notNull": false, | |
| 308 | - | "autoincrement": false | |
| 309 | - | }, | |
| 310 | - | "last_seen": { | |
| 311 | - | "name": "last_seen", | |
| 312 | - | "type": "integer", | |
| 313 | - | "primaryKey": false, | |
| 314 | - | "notNull": false, | |
| 315 | - | "autoincrement": false | |
| 316 | - | } | |
| 317 | - | }, | |
| 318 | - | "name": "user_view", | |
| 319 | - | "isExisting": false, | |
| 320 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 321 | - | } | |
| 322 | - | }, | |
| 323 | - | "enums": {}, | |
| 324 | - | "_meta": { | |
| 325 | - | "schemas": {}, | |
| 326 | - | "tables": {}, | |
| 327 | - | "columns": {} | |
| 328 | - | }, | |
| 329 | - | "internal": { | |
| 330 | - | "indexes": {} | |
| 331 | - | } | |
| 332 | - | } | |
D drizzle/meta/0001_snapshot.json +0 -367
| @@ -1,367 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "version": "6", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "id": "9090026e-dae4-4b69-8539-471ebf6ec7e6", | |
| 5 | - | "prevId": "16b13426-2b9d-4084-8e17-71f9de0f72d0", | |
| 6 | - | "tables": { | |
| 7 | - | "chat_rooms": { | |
| 8 | - | "name": "chat_rooms", | |
| 9 | - | "columns": { | |
| 10 | - | "id": { | |
| 11 | - | "name": "id", | |
| 12 | - | "type": "text", | |
| 13 | - | "primaryKey": true, | |
| 14 | - | "notNull": true, | |
| 15 | - | "autoincrement": false | |
| 16 | - | }, | |
| 17 | - | "name": { | |
| 18 | - | "name": "name", | |
| 19 | - | "type": "text", | |
| 20 | - | "primaryKey": false, | |
| 21 | - | "notNull": false, | |
| 22 | - | "autoincrement": false | |
| 23 | - | }, | |
| 24 | - | "type": { | |
| 25 | - | "name": "type", | |
| 26 | - | "type": "text", | |
| 27 | - | "primaryKey": false, | |
| 28 | - | "notNull": true, | |
| 29 | - | "autoincrement": false, | |
| 30 | - | "default": "'direct'" | |
| 31 | - | }, | |
| 32 | - | "created_at": { | |
| 33 | - | "name": "created_at", | |
| 34 | - | "type": "integer", | |
| 35 | - | "primaryKey": false, | |
| 36 | - | "notNull": true, | |
| 37 | - | "autoincrement": false | |
| 38 | - | } | |
| 39 | - | }, | |
| 40 | - | "indexes": {}, | |
| 41 | - | "foreignKeys": {}, | |
| 42 | - | "compositePrimaryKeys": {}, | |
| 43 | - | "uniqueConstraints": {}, | |
| 44 | - | "checkConstraints": {} | |
| 45 | - | }, | |
| 46 | - | "messages": { | |
| 47 | - | "name": "messages", | |
| 48 | - | "columns": { | |
| 49 | - | "id": { | |
| 50 | - | "name": "id", | |
| 51 | - | "type": "text", | |
| 52 | - | "primaryKey": true, | |
| 53 | - | "notNull": true, | |
| 54 | - | "autoincrement": false | |
| 55 | - | }, | |
| 56 | - | "chat_room_id": { | |
| 57 | - | "name": "chat_room_id", | |
| 58 | - | "type": "text", | |
| 59 | - | "primaryKey": false, | |
| 60 | - | "notNull": true, | |
| 61 | - | "autoincrement": false | |
| 62 | - | }, | |
| 63 | - | "sender_id": { | |
| 64 | - | "name": "sender_id", | |
| 65 | - | "type": "text", | |
| 66 | - | "primaryKey": false, | |
| 67 | - | "notNull": true, | |
| 68 | - | "autoincrement": false | |
| 69 | - | }, | |
| 70 | - | "content": { | |
| 71 | - | "name": "content", | |
| 72 | - | "type": "text", | |
| 73 | - | "primaryKey": false, | |
| 74 | - | "notNull": true, | |
| 75 | - | "autoincrement": false | |
| 76 | - | }, | |
| 77 | - | "type": { | |
| 78 | - | "name": "type", | |
| 79 | - | "type": "text", | |
| 80 | - | "primaryKey": false, | |
| 81 | - | "notNull": true, | |
| 82 | - | "autoincrement": false, | |
| 83 | - | "default": "'chat'" | |
| 84 | - | }, | |
| 85 | - | "timestamp": { | |
| 86 | - | "name": "timestamp", | |
| 87 | - | "type": "integer", | |
| 88 | - | "primaryKey": false, | |
| 89 | - | "notNull": true, | |
| 90 | - | "autoincrement": false | |
| 91 | - | } | |
| 92 | - | }, | |
| 93 | - | "indexes": {}, | |
| 94 | - | "foreignKeys": { | |
| 95 | - | "messages_chat_room_id_chat_rooms_id_fk": { | |
| 96 | - | "name": "messages_chat_room_id_chat_rooms_id_fk", | |
| 97 | - | "tableFrom": "messages", | |
| 98 | - | "tableTo": "chat_rooms", | |
| 99 | - | "columnsFrom": ["chat_room_id"], | |
| 100 | - | "columnsTo": ["id"], | |
| 101 | - | "onDelete": "no action", | |
| 102 | - | "onUpdate": "no action" | |
| 103 | - | }, | |
| 104 | - | "messages_sender_id_users_id_fk": { | |
| 105 | - | "name": "messages_sender_id_users_id_fk", | |
| 106 | - | "tableFrom": "messages", | |
| 107 | - | "tableTo": "users", | |
| 108 | - | "columnsFrom": ["sender_id"], | |
| 109 | - | "columnsTo": ["id"], | |
| 110 | - | "onDelete": "no action", | |
| 111 | - | "onUpdate": "no action" | |
| 112 | - | } | |
| 113 | - | }, | |
| 114 | - | "compositePrimaryKeys": {}, | |
| 115 | - | "uniqueConstraints": {}, | |
| 116 | - | "checkConstraints": {} | |
| 117 | - | }, | |
| 118 | - | "sessions": { | |
| 119 | - | "name": "sessions", | |
| 120 | - | "columns": { | |
| 121 | - | "id": { | |
| 122 | - | "name": "id", | |
| 123 | - | "type": "text", | |
| 124 | - | "primaryKey": true, | |
| 125 | - | "notNull": true, | |
| 126 | - | "autoincrement": false | |
| 127 | - | }, | |
| 128 | - | "user_id": { | |
| 129 | - | "name": "user_id", | |
| 130 | - | "type": "text", | |
| 131 | - | "primaryKey": false, | |
| 132 | - | "notNull": true, | |
| 133 | - | "autoincrement": false | |
| 134 | - | }, | |
| 135 | - | "expires_at": { | |
| 136 | - | "name": "expires_at", | |
| 137 | - | "type": "integer", | |
| 138 | - | "primaryKey": false, | |
| 139 | - | "notNull": true, | |
| 140 | - | "autoincrement": false | |
| 141 | - | }, | |
| 142 | - | "created_at": { | |
| 143 | - | "name": "created_at", | |
| 144 | - | "type": "integer", | |
| 145 | - | "primaryKey": false, | |
| 146 | - | "notNull": true, | |
| 147 | - | "autoincrement": false | |
| 148 | - | } | |
| 149 | - | }, | |
| 150 | - | "indexes": {}, | |
| 151 | - | "foreignKeys": { | |
| 152 | - | "sessions_user_id_users_id_fk": { | |
| 153 | - | "name": "sessions_user_id_users_id_fk", | |
| 154 | - | "tableFrom": "sessions", | |
| 155 | - | "tableTo": "users", | |
| 156 | - | "columnsFrom": ["user_id"], | |
| 157 | - | "columnsTo": ["id"], | |
| 158 | - | "onDelete": "no action", | |
| 159 | - | "onUpdate": "no action" | |
| 160 | - | } | |
| 161 | - | }, | |
| 162 | - | "compositePrimaryKeys": {}, | |
| 163 | - | "uniqueConstraints": {}, | |
| 164 | - | "checkConstraints": {} | |
| 165 | - | }, | |
| 166 | - | "users": { | |
| 167 | - | "name": "users", | |
| 168 | - | "columns": { | |
| 169 | - | "id": { | |
| 170 | - | "name": "id", | |
| 171 | - | "type": "text", | |
| 172 | - | "primaryKey": true, | |
| 173 | - | "notNull": true, | |
| 174 | - | "autoincrement": false | |
| 175 | - | }, | |
| 176 | - | "password": { | |
| 177 | - | "name": "password", | |
| 178 | - | "type": "text", | |
| 179 | - | "primaryKey": false, | |
| 180 | - | "notNull": true, | |
| 181 | - | "autoincrement": false | |
| 182 | - | }, | |
| 183 | - | "nickname": { | |
| 184 | - | "name": "nickname", | |
| 185 | - | "type": "text", | |
| 186 | - | "primaryKey": false, | |
| 187 | - | "notNull": true, | |
| 188 | - | "autoincrement": false | |
| 189 | - | }, | |
| 190 | - | "status": { | |
| 191 | - | "name": "status", | |
| 192 | - | "type": "text", | |
| 193 | - | "primaryKey": false, | |
| 194 | - | "notNull": true, | |
| 195 | - | "autoincrement": false, | |
| 196 | - | "default": "'offline'" | |
| 197 | - | }, | |
| 198 | - | "avatar_url": { | |
| 199 | - | "name": "avatar_url", | |
| 200 | - | "type": "text", | |
| 201 | - | "primaryKey": false, | |
| 202 | - | "notNull": false, | |
| 203 | - | "autoincrement": false | |
| 204 | - | }, | |
| 205 | - | "created_at": { | |
| 206 | - | "name": "created_at", | |
| 207 | - | "type": "integer", | |
| 208 | - | "primaryKey": false, | |
| 209 | - | "notNull": true, | |
| 210 | - | "autoincrement": false | |
| 211 | - | }, | |
| 212 | - | "last_seen": { | |
| 213 | - | "name": "last_seen", | |
| 214 | - | "type": "integer", | |
| 215 | - | "primaryKey": false, | |
| 216 | - | "notNull": false, | |
| 217 | - | "autoincrement": false | |
| 218 | - | } | |
| 219 | - | }, | |
| 220 | - | "indexes": {}, | |
| 221 | - | "foreignKeys": {}, | |
| 222 | - | "compositePrimaryKeys": {}, | |
| 223 | - | "uniqueConstraints": {}, | |
| 224 | - | "checkConstraints": {} | |
| 225 | - | } | |
| 226 | - | }, | |
| 227 | - | "views": { | |
| 228 | - | "chat_room_view": { | |
| 229 | - | "columns": { | |
| 230 | - | "id": { | |
| 231 | - | "name": "id", | |
| 232 | - | "type": "text", | |
| 233 | - | "primaryKey": true, | |
| 234 | - | "notNull": true, | |
| 235 | - | "autoincrement": false | |
| 236 | - | }, | |
| 237 | - | "name": { | |
| 238 | - | "name": "name", | |
| 239 | - | "type": "text", | |
| 240 | - | "primaryKey": false, | |
| 241 | - | "notNull": false, | |
| 242 | - | "autoincrement": false | |
| 243 | - | }, | |
| 244 | - | "type": { | |
| 245 | - | "name": "type", | |
| 246 | - | "type": "text", | |
| 247 | - | "primaryKey": false, | |
| 248 | - | "notNull": true, | |
| 249 | - | "autoincrement": false, | |
| 250 | - | "default": "'direct'" | |
| 251 | - | }, | |
| 252 | - | "created_at": { | |
| 253 | - | "name": "created_at", | |
| 254 | - | "type": "integer", | |
| 255 | - | "primaryKey": false, | |
| 256 | - | "notNull": true, | |
| 257 | - | "autoincrement": false | |
| 258 | - | } | |
| 259 | - | }, | |
| 260 | - | "name": "chat_room_view", | |
| 261 | - | "isExisting": false, | |
| 262 | - | "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\"" | |
| 263 | - | }, | |
| 264 | - | "message_view": { | |
| 265 | - | "columns": { | |
| 266 | - | "id": { | |
| 267 | - | "name": "id", | |
| 268 | - | "type": "text", | |
| 269 | - | "primaryKey": true, | |
| 270 | - | "notNull": true, | |
| 271 | - | "autoincrement": false | |
| 272 | - | }, | |
| 273 | - | "content": { | |
| 274 | - | "name": "content", | |
| 275 | - | "type": "text", | |
| 276 | - | "primaryKey": false, | |
| 277 | - | "notNull": true, | |
| 278 | - | "autoincrement": false | |
| 279 | - | }, | |
| 280 | - | "type": { | |
| 281 | - | "name": "type", | |
| 282 | - | "type": "text", | |
| 283 | - | "primaryKey": false, | |
| 284 | - | "notNull": true, | |
| 285 | - | "autoincrement": false, | |
| 286 | - | "default": "'chat'" | |
| 287 | - | }, | |
| 288 | - | "timestamp": { | |
| 289 | - | "name": "timestamp", | |
| 290 | - | "type": "integer", | |
| 291 | - | "primaryKey": false, | |
| 292 | - | "notNull": true, | |
| 293 | - | "autoincrement": false | |
| 294 | - | }, | |
| 295 | - | "nickname": { | |
| 296 | - | "name": "nickname", | |
| 297 | - | "type": "text", | |
| 298 | - | "primaryKey": false, | |
| 299 | - | "notNull": true, | |
| 300 | - | "autoincrement": false | |
| 301 | - | }, | |
| 302 | - | "name": { | |
| 303 | - | "name": "name", | |
| 304 | - | "type": "text", | |
| 305 | - | "primaryKey": false, | |
| 306 | - | "notNull": false, | |
| 307 | - | "autoincrement": false | |
| 308 | - | } | |
| 309 | - | }, | |
| 310 | - | "name": "message_view", | |
| 311 | - | "isExisting": false, | |
| 312 | - | "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\"" | |
| 313 | - | }, | |
| 314 | - | "user_view": { | |
| 315 | - | "columns": { | |
| 316 | - | "id": { | |
| 317 | - | "name": "id", | |
| 318 | - | "type": "text", | |
| 319 | - | "primaryKey": true, | |
| 320 | - | "notNull": true, | |
| 321 | - | "autoincrement": false | |
| 322 | - | }, | |
| 323 | - | "nickname": { | |
| 324 | - | "name": "nickname", | |
| 325 | - | "type": "text", | |
| 326 | - | "primaryKey": false, | |
| 327 | - | "notNull": true, | |
| 328 | - | "autoincrement": false | |
| 329 | - | }, | |
| 330 | - | "status": { | |
| 331 | - | "name": "status", | |
| 332 | - | "type": "text", | |
| 333 | - | "primaryKey": false, | |
| 334 | - | "notNull": true, | |
| 335 | - | "autoincrement": false, | |
| 336 | - | "default": "'offline'" | |
| 337 | - | }, | |
| 338 | - | "avatar_url": { | |
| 339 | - | "name": "avatar_url", | |
| 340 | - | "type": "text", | |
| 341 | - | "primaryKey": false, | |
| 342 | - | "notNull": false, | |
| 343 | - | "autoincrement": false | |
| 344 | - | }, | |
| 345 | - | "last_seen": { | |
| 346 | - | "name": "last_seen", | |
| 347 | - | "type": "integer", | |
| 348 | - | "primaryKey": false, | |
| 349 | - | "notNull": false, | |
| 350 | - | "autoincrement": false | |
| 351 | - | } | |
| 352 | - | }, | |
| 353 | - | "name": "user_view", | |
| 354 | - | "isExisting": false, | |
| 355 | - | "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\"" | |
| 356 | - | } | |
| 357 | - | }, | |
| 358 | - | "enums": {}, | |
| 359 | - | "_meta": { | |
| 360 | - | "schemas": {}, | |
| 361 | - | "tables": {}, | |
| 362 | - | "columns": {} | |
| 363 | - | }, | |
| 364 | - | "internal": { | |
| 365 | - | "indexes": {} | |
| 366 | - | } | |
| 367 | - | } | |
D drizzle/meta/_journal.json +0 -20
| @@ -1,20 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "version": "7", | |
| 3 | - | "dialect": "sqlite", | |
| 4 | - | "entries": [ | |
| 5 | - | { | |
| 6 | - | "idx": 0, | |
| 7 | - | "version": "6", | |
| 8 | - | "when": 1739208055141, | |
| 9 | - | "tag": "0000_shallow_ghost_rider", | |
| 10 | - | "breakpoints": true | |
| 11 | - | }, | |
| 12 | - | { | |
| 13 | - | "idx": 1, | |
| 14 | - | "version": "6", | |
| 15 | - | "when": 1739432549447, | |
| 16 | - | "tag": "0001_blushing_jean_grey", | |
| 17 | - | "breakpoints": true | |
| 18 | - | } | |
| 19 | - | ] | |
| 20 | - | } | |
M eslint.config.js +3 -0
| @@ -9,6 +9,9 @@ const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); | |||
| 9 | 9 | ||
| 10 | 10 | export default ts.config( | |
| 11 | 11 | includeIgnoreFile(gitignorePath), | |
| 12 | + | { | |
| 13 | + | ignores: ['src/convex/_generated/**'] | |
| 14 | + | }, | |
| 12 | 15 | js.configs.recommended, | |
| 13 | 16 | ...ts.configs.recommended, | |
| 14 | 17 | ...svelte.configs['flat/recommended'], | |
M package.json +3 -0
| @@ -58,6 +58,9 @@ | |||
| 58 | 58 | "dependencies": { | |
| 59 | 59 | "@libsql/client": "^0.17.0", | |
| 60 | 60 | "@lucide/svelte": "^0.562.0", | |
| 61 | + | "convex": "^1.31.5", | |
| 62 | + | "convex-helpers": "^0.1.111", | |
| 63 | + | "convex-svelte": "^0.0.12", | |
| 61 | 64 | "dotenv": "^17.2.3", | |
| 62 | 65 | "drizzle-orm": "^0.45.1", | |
| 63 | 66 | "pino": "^10.2.0", | |
M pnpm-lock.yaml +83 -0 generated file: collapsed
M src/app.d.ts +2 -2
| @@ -1,12 +1,12 @@ | |||
| 1 | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | |
| 2 | 2 | // for information about these interfaces | |
| 3 | - | import type { User, Session } from '$lib/types/chat'; | |
| 3 | + | import type { SafeUser, Session } from '$lib/types/chat'; | |
| 4 | 4 | ||
| 5 | 5 | declare global { | |
| 6 | 6 | namespace App { | |
| 7 | 7 | // interface Error {} | |
| 8 | 8 | interface Locals { | |
| 9 | - | user: User | null; | |
| 9 | + | user: SafeUser | null; | |
| 10 | 10 | session: Session | null; | |
| 11 | 11 | } | |
| 12 | 12 | // interface PageData {} | |
A src/convex/README.md +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | # Welcome to your Convex functions directory! | |
| 2 | + | ||
| 3 | + | Write your Convex functions here. | |
| 4 | + | See https://docs.convex.dev/functions for more. | |
| 5 | + | ||
| 6 | + | A query function that takes two arguments looks like: | |
| 7 | + | ||
| 8 | + | ```ts | |
| 9 | + | // convex/myFunctions.ts | |
| 10 | + | import { query } from './_generated/server'; | |
| 11 | + | import { v } from 'convex/values'; | |
| 12 | + | ||
| 13 | + | export const myQueryFunction = query({ | |
| 14 | + | // Validators for arguments. | |
| 15 | + | args: { | |
| 16 | + | first: v.number(), | |
| 17 | + | second: v.string() | |
| 18 | + | }, | |
| 19 | + | ||
| 20 | + | // Function implementation. | |
| 21 | + | handler: async (ctx, args) => { | |
| 22 | + | // Read the database as many times as you need here. | |
| 23 | + | // See https://docs.convex.dev/database/reading-data. | |
| 24 | + | const documents = await ctx.db.query('tablename').collect(); | |
| 25 | + | ||
| 26 | + | // Arguments passed from the client are properties of the args object. | |
| 27 | + | console.log(args.first, args.second); | |
| 28 | + | ||
| 29 | + | // Write arbitrary JavaScript here: filter, aggregate, build derived data, | |
| 30 | + | // remove non-public properties, or create new objects. | |
| 31 | + | return documents; | |
| 32 | + | } | |
| 33 | + | }); | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | Using this query function in a React component looks like: | |
| 37 | + | ||
| 38 | + | ```ts | |
| 39 | + | const data = useQuery(api.myFunctions.myQueryFunction, { | |
| 40 | + | first: 10, | |
| 41 | + | second: 'hello' | |
| 42 | + | }); | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | A mutation function looks like: | |
| 46 | + | ||
| 47 | + | ```ts | |
| 48 | + | // convex/myFunctions.ts | |
| 49 | + | import { mutation } from './_generated/server'; | |
| 50 | + | import { v } from 'convex/values'; | |
| 51 | + | ||
| 52 | + | export const myMutationFunction = mutation({ | |
| 53 | + | // Validators for arguments. | |
| 54 | + | args: { | |
| 55 | + | first: v.string(), | |
| 56 | + | second: v.string() | |
| 57 | + | }, | |
| 58 | + | ||
| 59 | + | // Function implementation. | |
| 60 | + | handler: async (ctx, args) => { | |
| 61 | + | // Insert or modify documents in the database here. | |
| 62 | + | // Mutations can also read from the database like queries. | |
| 63 | + | // See https://docs.convex.dev/database/writing-data. | |
| 64 | + | const message = { body: args.first, author: args.second }; | |
| 65 | + | const id = await ctx.db.insert('messages', message); | |
| 66 | + | ||
| 67 | + | // Optionally, return a value from your mutation. | |
| 68 | + | return await ctx.db.get('messages', id); | |
| 69 | + | } | |
| 70 | + | }); | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | Using this mutation function in a React component looks like: | |
| 74 | + | ||
| 75 | + | ```ts | |
| 76 | + | const mutation = useMutation(api.myFunctions.myMutationFunction); | |
| 77 | + | function handleButtonPress() { | |
| 78 | + | // fire and forget, the most common way to use mutations | |
| 79 | + | mutation({ first: 'Hello!', second: 'me' }); | |
| 80 | + | // OR | |
| 81 | + | // use the result once the mutation has completed | |
| 82 | + | mutation({ first: 'Hello!', second: 'me' }).then((result) => console.log(result)); | |
| 83 | + | } | |
| 84 | + | ``` | |
| 85 | + | ||
| 86 | + | Use the Convex CLI to push your functions to a deployment. See everything | |
| 87 | + | the Convex CLI can do by running `npx convex -h` in your project root | |
| 88 | + | directory. To learn more, launch the docs with `npx convex docs`. | |
A src/convex/_generated/api.d.ts +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated `api` utility. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import type * as auth from "../auth.js"; | |
| 12 | + | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | |
| 13 | + | import type * as http from "../http.js"; | |
| 14 | + | import type * as messagesInternal from "../messagesInternal.js"; | |
| 15 | + | import type * as mutations from "../mutations.js"; | |
| 16 | + | import type * as queries from "../queries.js"; | |
| 17 | + | import type * as sessionsInternal from "../sessionsInternal.js"; | |
| 18 | + | import type * as textPreferencesInternal from "../textPreferencesInternal.js"; | |
| 19 | + | import type * as usersInternal from "../usersInternal.js"; | |
| 20 | + | ||
| 21 | + | import type { | |
| 22 | + | ApiFromModules, | |
| 23 | + | FilterApi, | |
| 24 | + | FunctionReference, | |
| 25 | + | } from "convex/server"; | |
| 26 | + | ||
| 27 | + | declare const fullApi: ApiFromModules<{ | |
| 28 | + | auth: typeof auth; | |
| 29 | + | chatRoomsInternal: typeof chatRoomsInternal; | |
| 30 | + | http: typeof http; | |
| 31 | + | messagesInternal: typeof messagesInternal; | |
| 32 | + | mutations: typeof mutations; | |
| 33 | + | queries: typeof queries; | |
| 34 | + | sessionsInternal: typeof sessionsInternal; | |
| 35 | + | textPreferencesInternal: typeof textPreferencesInternal; | |
| 36 | + | usersInternal: typeof usersInternal; | |
| 37 | + | }>; | |
| 38 | + | ||
| 39 | + | /** | |
| 40 | + | * A utility for referencing Convex functions in your app's public API. | |
| 41 | + | * | |
| 42 | + | * Usage: | |
| 43 | + | * ```js | |
| 44 | + | * const myFunctionReference = api.myModule.myFunction; | |
| 45 | + | * ``` | |
| 46 | + | */ | |
| 47 | + | export declare const api: FilterApi< | |
| 48 | + | typeof fullApi, | |
| 49 | + | FunctionReference<any, "public"> | |
| 50 | + | >; | |
| 51 | + | ||
| 52 | + | /** | |
| 53 | + | * A utility for referencing Convex functions in your app's internal API. | |
| 54 | + | * | |
| 55 | + | * Usage: | |
| 56 | + | * ```js | |
| 57 | + | * const myFunctionReference = internal.myModule.myFunction; | |
| 58 | + | * ``` | |
| 59 | + | */ | |
| 60 | + | export declare const internal: FilterApi< | |
| 61 | + | typeof fullApi, | |
| 62 | + | FunctionReference<any, "internal"> | |
| 63 | + | >; | |
| 64 | + | ||
| 65 | + | export declare const components: {}; | |
A src/convex/_generated/api.js +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated `api` utility. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { anyApi, componentsGeneric } from "convex/server"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * A utility for referencing Convex functions in your app's API. | |
| 15 | + | * | |
| 16 | + | * Usage: | |
| 17 | + | * ```js | |
| 18 | + | * const myFunctionReference = api.myModule.myFunction; | |
| 19 | + | * ``` | |
| 20 | + | */ | |
| 21 | + | export const api = anyApi; | |
| 22 | + | export const internal = anyApi; | |
| 23 | + | export const components = componentsGeneric(); | |
A src/convex/_generated/dataModel.d.ts +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated data model types. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import type { | |
| 12 | + | DataModelFromSchemaDefinition, | |
| 13 | + | DocumentByName, | |
| 14 | + | TableNamesInDataModel, | |
| 15 | + | SystemTableNames, | |
| 16 | + | } from "convex/server"; | |
| 17 | + | import type { GenericId } from "convex/values"; | |
| 18 | + | import schema from "../schema.js"; | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * The names of all of your Convex tables. | |
| 22 | + | */ | |
| 23 | + | export type TableNames = TableNamesInDataModel<DataModel>; | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The type of a document stored in Convex. | |
| 27 | + | * | |
| 28 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 29 | + | */ | |
| 30 | + | export type Doc<TableName extends TableNames> = DocumentByName< | |
| 31 | + | DataModel, | |
| 32 | + | TableName | |
| 33 | + | >; | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * An identifier for a document in Convex. | |
| 37 | + | * | |
| 38 | + | * Convex documents are uniquely identified by their `Id`, which is accessible | |
| 39 | + | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). | |
| 40 | + | * | |
| 41 | + | * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. | |
| 42 | + | * | |
| 43 | + | * IDs are just strings at runtime, but this type can be used to distinguish them from other | |
| 44 | + | * strings when type checking. | |
| 45 | + | * | |
| 46 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 47 | + | */ | |
| 48 | + | export type Id<TableName extends TableNames | SystemTableNames> = | |
| 49 | + | GenericId<TableName>; | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * A type describing your Convex data model. | |
| 53 | + | * | |
| 54 | + | * This type includes information about what tables you have, the type of | |
| 55 | + | * documents stored in those tables, and the indexes defined on them. | |
| 56 | + | * | |
| 57 | + | * This type is used to parameterize methods like `queryGeneric` and | |
| 58 | + | * `mutationGeneric` to make them type-safe. | |
| 59 | + | */ | |
| 60 | + | export type DataModel = DataModelFromSchemaDefinition<typeof schema>; | |
A src/convex/_generated/server.d.ts +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | ActionBuilder, | |
| 13 | + | HttpActionBuilder, | |
| 14 | + | MutationBuilder, | |
| 15 | + | QueryBuilder, | |
| 16 | + | GenericActionCtx, | |
| 17 | + | GenericMutationCtx, | |
| 18 | + | GenericQueryCtx, | |
| 19 | + | GenericDatabaseReader, | |
| 20 | + | GenericDatabaseWriter, | |
| 21 | + | } from "convex/server"; | |
| 22 | + | import type { DataModel } from "./dataModel.js"; | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * Define a query in this Convex app's public API. | |
| 26 | + | * | |
| 27 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 28 | + | * | |
| 29 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 30 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 31 | + | */ | |
| 32 | + | export declare const query: QueryBuilder<DataModel, "public">; | |
| 33 | + | ||
| 34 | + | /** | |
| 35 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 36 | + | * | |
| 37 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 38 | + | * | |
| 39 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 40 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 41 | + | */ | |
| 42 | + | export declare const internalQuery: QueryBuilder<DataModel, "internal">; | |
| 43 | + | ||
| 44 | + | /** | |
| 45 | + | * Define a mutation in this Convex app's public API. | |
| 46 | + | * | |
| 47 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 48 | + | * | |
| 49 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 50 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 51 | + | */ | |
| 52 | + | export declare const mutation: MutationBuilder<DataModel, "public">; | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 56 | + | * | |
| 57 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 58 | + | * | |
| 59 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 60 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 61 | + | */ | |
| 62 | + | export declare const internalMutation: MutationBuilder<DataModel, "internal">; | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * Define an action in this Convex app's public API. | |
| 66 | + | * | |
| 67 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 68 | + | * code and code with side-effects, like calling third-party services. | |
| 69 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 70 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 71 | + | * | |
| 72 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 73 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 74 | + | */ | |
| 75 | + | export declare const action: ActionBuilder<DataModel, "public">; | |
| 76 | + | ||
| 77 | + | /** | |
| 78 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 79 | + | * | |
| 80 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 81 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 82 | + | */ | |
| 83 | + | export declare const internalAction: ActionBuilder<DataModel, "internal">; | |
| 84 | + | ||
| 85 | + | /** | |
| 86 | + | * Define an HTTP action. | |
| 87 | + | * | |
| 88 | + | * The wrapped function will be used to respond to HTTP requests received | |
| 89 | + | * by a Convex deployment if the requests matches the path and method where | |
| 90 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 91 | + | * | |
| 92 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 93 | + | * and a Fetch API `Request` object as its second. | |
| 94 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 95 | + | */ | |
| 96 | + | export declare const httpAction: HttpActionBuilder; | |
| 97 | + | ||
| 98 | + | /** | |
| 99 | + | * A set of services for use within Convex query functions. | |
| 100 | + | * | |
| 101 | + | * The query context is passed as the first argument to any Convex query | |
| 102 | + | * function run on the server. | |
| 103 | + | * | |
| 104 | + | * This differs from the {@link MutationCtx} because all of the services are | |
| 105 | + | * read-only. | |
| 106 | + | */ | |
| 107 | + | export type QueryCtx = GenericQueryCtx<DataModel>; | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * A set of services for use within Convex mutation functions. | |
| 111 | + | * | |
| 112 | + | * The mutation context is passed as the first argument to any Convex mutation | |
| 113 | + | * function run on the server. | |
| 114 | + | */ | |
| 115 | + | export type MutationCtx = GenericMutationCtx<DataModel>; | |
| 116 | + | ||
| 117 | + | /** | |
| 118 | + | * A set of services for use within Convex action functions. | |
| 119 | + | * | |
| 120 | + | * The action context is passed as the first argument to any Convex action | |
| 121 | + | * function run on the server. | |
| 122 | + | */ | |
| 123 | + | export type ActionCtx = GenericActionCtx<DataModel>; | |
| 124 | + | ||
| 125 | + | /** | |
| 126 | + | * An interface to read from the database within Convex query functions. | |
| 127 | + | * | |
| 128 | + | * The two entry points are {@link DatabaseReader.get}, which fetches a single | |
| 129 | + | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts | |
| 130 | + | * building a query. | |
| 131 | + | */ | |
| 132 | + | export type DatabaseReader = GenericDatabaseReader<DataModel>; | |
| 133 | + | ||
| 134 | + | /** | |
| 135 | + | * An interface to read from and write to the database within Convex mutation | |
| 136 | + | * functions. | |
| 137 | + | * | |
| 138 | + | * Convex guarantees that all writes within a single mutation are | |
| 139 | + | * executed atomically, so you never have to worry about partial writes leaving | |
| 140 | + | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) | |
| 141 | + | * for the guarantees Convex provides your functions. | |
| 142 | + | */ | |
| 143 | + | export type DatabaseWriter = GenericDatabaseWriter<DataModel>; | |
A src/convex/_generated/server.js +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | actionGeneric, | |
| 13 | + | httpActionGeneric, | |
| 14 | + | queryGeneric, | |
| 15 | + | mutationGeneric, | |
| 16 | + | internalActionGeneric, | |
| 17 | + | internalMutationGeneric, | |
| 18 | + | internalQueryGeneric, | |
| 19 | + | } from "convex/server"; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * Define a query in this Convex app's public API. | |
| 23 | + | * | |
| 24 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 25 | + | * | |
| 26 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 27 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 28 | + | */ | |
| 29 | + | export const query = queryGeneric; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 33 | + | * | |
| 34 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 35 | + | * | |
| 36 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 37 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 38 | + | */ | |
| 39 | + | export const internalQuery = internalQueryGeneric; | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * Define a mutation in this Convex app's public API. | |
| 43 | + | * | |
| 44 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 45 | + | * | |
| 46 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 47 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 48 | + | */ | |
| 49 | + | export const mutation = mutationGeneric; | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 53 | + | * | |
| 54 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 55 | + | * | |
| 56 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 57 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 58 | + | */ | |
| 59 | + | export const internalMutation = internalMutationGeneric; | |
| 60 | + | ||
| 61 | + | /** | |
| 62 | + | * Define an action in this Convex app's public API. | |
| 63 | + | * | |
| 64 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 65 | + | * code and code with side-effects, like calling third-party services. | |
| 66 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 67 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 68 | + | * | |
| 69 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 70 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 71 | + | */ | |
| 72 | + | export const action = actionGeneric; | |
| 73 | + | ||
| 74 | + | /** | |
| 75 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 76 | + | * | |
| 77 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 78 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 79 | + | */ | |
| 80 | + | export const internalAction = internalActionGeneric; | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Define an HTTP action. | |
| 84 | + | * | |
| 85 | + | * The wrapped function will be used to respond to HTTP requests received | |
| 86 | + | * by a Convex deployment if the requests matches the path and method where | |
| 87 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 88 | + | * | |
| 89 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 90 | + | * and a Fetch API `Request` object as its second. | |
| 91 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 92 | + | */ | |
| 93 | + | export const httpAction = httpActionGeneric; | |
A src/convex/auth.ts +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | import { query, mutation } from './_generated/server'; | |
| 2 | + | import { customQuery, customMutation } from 'convex-helpers/server/customFunctions'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | import type { Doc } from './_generated/dataModel'; | |
| 5 | + | import type { QueryCtx, MutationCtx } from './_generated/server'; | |
| 6 | + | ||
| 7 | + | // Helper to validate session and get user | |
| 8 | + | async function validateSession( | |
| 9 | + | ctx: QueryCtx | MutationCtx, | |
| 10 | + | tokenHash: string | |
| 11 | + | ): Promise<{ session: Doc<'sessions'>; user: Doc<'users'> }> { | |
| 12 | + | const session = await ctx.db | |
| 13 | + | .query('sessions') | |
| 14 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', tokenHash)) | |
| 15 | + | .first(); | |
| 16 | + | ||
| 17 | + | if (!session) { | |
| 18 | + | throw new Error('Invalid session'); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | if (session.expiresAt < Date.now()) { | |
| 22 | + | throw new Error('Session expired'); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | const user = await ctx.db.get(session.userId); | |
| 26 | + | if (!user) { | |
| 27 | + | throw new Error('User not found'); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return { session, user }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Authenticated query - requires tokenHash, provides user in context | |
| 34 | + | export const authQuery = customQuery(query, { | |
| 35 | + | args: { tokenHash: v.string() }, | |
| 36 | + | input: async (ctx, { tokenHash }) => { | |
| 37 | + | const { session, user } = await validateSession(ctx, tokenHash); | |
| 38 | + | return { ctx: { session, user }, args: {} }; | |
| 39 | + | } | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | // Authenticated mutation - requires tokenHash, provides user in context | |
| 43 | + | export const authMutation = customMutation(mutation, { | |
| 44 | + | args: { tokenHash: v.string() }, | |
| 45 | + | input: async (ctx, { tokenHash }) => { | |
| 46 | + | const { session, user } = await validateSession(ctx, tokenHash); | |
| 47 | + | return { ctx: { session, user }, args: {} }; | |
| 48 | + | } | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | // Type helpers for use in handlers | |
| 52 | + | export type AuthQueryCtx = QueryCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |
| 53 | + | export type AuthMutationCtx = MutationCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |
A src/convex/chatRoomsInternal.ts +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get chat room by ID (internal) | |
| 5 | + | export const getById = internalQuery({ | |
| 6 | + | args: { id: v.id('chatRooms') }, | |
| 7 | + | handler: async (ctx, args) => { | |
| 8 | + | return await ctx.db.get(args.id); | |
| 9 | + | } | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | // List all chat rooms (internal) | |
| 13 | + | export const list = internalQuery({ | |
| 14 | + | args: {}, | |
| 15 | + | handler: async (ctx) => { | |
| 16 | + | return await ctx.db.query('chatRooms').collect(); | |
| 17 | + | } | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | // Create a chat room (internal) | |
| 21 | + | export const create = internalMutation({ | |
| 22 | + | args: { | |
| 23 | + | name: v.optional(v.string()), | |
| 24 | + | type: v.string() | |
| 25 | + | }, | |
| 26 | + | handler: async (ctx, args) => { | |
| 27 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 28 | + | name: args.name, | |
| 29 | + | type: args.type, | |
| 30 | + | createdAt: Date.now() | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | return await ctx.db.get(roomId); | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // Get or create the default "General" room (internal) | |
| 38 | + | export const getOrCreateDefault = internalMutation({ | |
| 39 | + | args: {}, | |
| 40 | + | handler: async (ctx) => { | |
| 41 | + | // Look for existing General room | |
| 42 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 43 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 44 | + | ||
| 45 | + | if (generalRoom) { | |
| 46 | + | return generalRoom; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Create default room | |
| 50 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 51 | + | name: 'General', | |
| 52 | + | type: 'group', | |
| 53 | + | createdAt: Date.now() | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | return await ctx.db.get(roomId); | |
| 57 | + | } | |
| 58 | + | }); | |
A src/convex/http.ts +392 -0
| @@ -0,0 +1,392 @@ | |||
| 1 | + | import { httpRouter } from 'convex/server'; | |
| 2 | + | import { httpAction } from './_generated/server'; | |
| 3 | + | import { internal } from './_generated/api'; | |
| 4 | + | import type { Id } from './_generated/dataModel'; | |
| 5 | + | ||
| 6 | + | const http = httpRouter(); | |
| 7 | + | ||
| 8 | + | // Validate the secret header for server-to-server calls | |
| 9 | + | function validateSecret(request: Request): boolean { | |
| 10 | + | const secret = request.headers.get('X-Convex-Secret'); | |
| 11 | + | const expectedSecret = process.env.CONVEX_API_SECRET; | |
| 12 | + | ||
| 13 | + | if (!expectedSecret) { | |
| 14 | + | console.error('CONVEX_API_SECRET environment variable not set'); | |
| 15 | + | return false; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | return secret === expectedSecret; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Generic error response | |
| 22 | + | function unauthorizedResponse() { | |
| 23 | + | return new Response(JSON.stringify({ error: 'Unauthorized' }), { | |
| 24 | + | status: 401, | |
| 25 | + | headers: { 'Content-Type': 'application/json' } | |
| 26 | + | }); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | function errorResponse(message: string, status = 400) { | |
| 30 | + | return new Response(JSON.stringify({ error: message }), { | |
| 31 | + | status, | |
| 32 | + | headers: { 'Content-Type': 'application/json' } | |
| 33 | + | }); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Helper function to safely parse JSON body | |
| 37 | + | async function parseJsonBody<T>(request: Request): Promise<T | null> { | |
| 38 | + | try { | |
| 39 | + | return (await request.json()) as T; | |
| 40 | + | } catch { | |
| 41 | + | return null; | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // ============ USERS (server-side only) ============ | |
| 46 | + | ||
| 47 | + | // Get user by nickname - for login | |
| 48 | + | http.route({ | |
| 49 | + | path: '/api/users/getByNickname', | |
| 50 | + | method: 'POST', | |
| 51 | + | handler: httpAction(async (ctx, request) => { | |
| 52 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 53 | + | ||
| 54 | + | const body = await parseJsonBody<{ nickname: string }>(request); | |
| 55 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 56 | + | ||
| 57 | + | const user = await ctx.runQuery(internal.usersInternal.getByNickname, { | |
| 58 | + | nickname: body.nickname | |
| 59 | + | }); | |
| 60 | + | ||
| 61 | + | return new Response(JSON.stringify(user), { | |
| 62 | + | headers: { 'Content-Type': 'application/json' } | |
| 63 | + | }); | |
| 64 | + | }) | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | // Create user - for registration | |
| 68 | + | http.route({ | |
| 69 | + | path: '/api/users/create', | |
| 70 | + | method: 'POST', | |
| 71 | + | handler: httpAction(async (ctx, request) => { | |
| 72 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 73 | + | ||
| 74 | + | const body = await parseJsonBody<{ nickname: string; password: string }>(request); | |
| 75 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 76 | + | ||
| 77 | + | try { | |
| 78 | + | const userId = await ctx.runMutation(internal.usersInternal.create, { | |
| 79 | + | nickname: body.nickname, | |
| 80 | + | password: body.password | |
| 81 | + | }); | |
| 82 | + | return new Response(JSON.stringify({ userId }), { | |
| 83 | + | headers: { 'Content-Type': 'application/json' } | |
| 84 | + | }); | |
| 85 | + | } catch (e) { | |
| 86 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create user'); | |
| 87 | + | } | |
| 88 | + | }) | |
| 89 | + | }); | |
| 90 | + | ||
| 91 | + | // Get user by ID | |
| 92 | + | http.route({ | |
| 93 | + | path: '/api/users/getById', | |
| 94 | + | method: 'POST', | |
| 95 | + | handler: httpAction(async (ctx, request) => { | |
| 96 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 97 | + | ||
| 98 | + | const body = await parseJsonBody<{ id: string }>(request); | |
| 99 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 100 | + | ||
| 101 | + | const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> }); | |
| 102 | + | ||
| 103 | + | return new Response(JSON.stringify(user), { | |
| 104 | + | headers: { 'Content-Type': 'application/json' } | |
| 105 | + | }); | |
| 106 | + | }) | |
| 107 | + | }); | |
| 108 | + | ||
| 109 | + | // List all users | |
| 110 | + | http.route({ | |
| 111 | + | path: '/api/users/list', | |
| 112 | + | method: 'POST', | |
| 113 | + | handler: httpAction(async (ctx, request) => { | |
| 114 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 115 | + | ||
| 116 | + | const users = await ctx.runQuery(internal.usersInternal.list); | |
| 117 | + | ||
| 118 | + | return new Response(JSON.stringify(users), { | |
| 119 | + | headers: { 'Content-Type': 'application/json' } | |
| 120 | + | }); | |
| 121 | + | }) | |
| 122 | + | }); | |
| 123 | + | ||
| 124 | + | // Update user status | |
| 125 | + | http.route({ | |
| 126 | + | path: '/api/users/updateStatus', | |
| 127 | + | method: 'POST', | |
| 128 | + | handler: httpAction(async (ctx, request) => { | |
| 129 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 130 | + | ||
| 131 | + | const body = await parseJsonBody<{ userId: string; status: string }>(request); | |
| 132 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 133 | + | ||
| 134 | + | try { | |
| 135 | + | const user = await ctx.runMutation(internal.usersInternal.updateStatus, { | |
| 136 | + | id: body.userId as Id<'users'>, | |
| 137 | + | status: body.status | |
| 138 | + | }); | |
| 139 | + | return new Response(JSON.stringify(user), { | |
| 140 | + | headers: { 'Content-Type': 'application/json' } | |
| 141 | + | }); | |
| 142 | + | } catch (e) { | |
| 143 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to update status'); | |
| 144 | + | } | |
| 145 | + | }) | |
| 146 | + | }); | |
| 147 | + | ||
| 148 | + | // Set all users offline (server startup) | |
| 149 | + | http.route({ | |
| 150 | + | path: '/api/users/setAllOffline', | |
| 151 | + | method: 'POST', | |
| 152 | + | handler: httpAction(async (ctx, request) => { | |
| 153 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 154 | + | ||
| 155 | + | const result = await ctx.runMutation(internal.usersInternal.setAllOffline); | |
| 156 | + | ||
| 157 | + | return new Response(JSON.stringify(result), { | |
| 158 | + | headers: { 'Content-Type': 'application/json' } | |
| 159 | + | }); | |
| 160 | + | }) | |
| 161 | + | }); | |
| 162 | + | ||
| 163 | + | // ============ SESSIONS (server-side only) ============ | |
| 164 | + | ||
| 165 | + | // Create session - for login | |
| 166 | + | http.route({ | |
| 167 | + | path: '/api/sessions/create', | |
| 168 | + | method: 'POST', | |
| 169 | + | handler: httpAction(async (ctx, request) => { | |
| 170 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 171 | + | ||
| 172 | + | const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>( | |
| 173 | + | request | |
| 174 | + | ); | |
| 175 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 176 | + | ||
| 177 | + | try { | |
| 178 | + | const result = await ctx.runMutation(internal.sessionsInternal.create, { | |
| 179 | + | tokenHash: body.tokenHash, | |
| 180 | + | userId: body.userId as Id<'users'>, | |
| 181 | + | expiresAt: body.expiresAt | |
| 182 | + | }); | |
| 183 | + | return new Response(JSON.stringify(result), { | |
| 184 | + | headers: { 'Content-Type': 'application/json' } | |
| 185 | + | }); | |
| 186 | + | } catch (e) { | |
| 187 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create session'); | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | }); | |
| 191 | + | ||
| 192 | + | // Validate session | |
| 193 | + | http.route({ | |
| 194 | + | path: '/api/sessions/validate', | |
| 195 | + | method: 'POST', | |
| 196 | + | handler: httpAction(async (ctx, request) => { | |
| 197 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 198 | + | ||
| 199 | + | const body = await parseJsonBody<{ tokenHash: string }>(request); | |
| 200 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 201 | + | ||
| 202 | + | const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, { | |
| 203 | + | tokenHash: body.tokenHash | |
| 204 | + | }); | |
| 205 | + | ||
| 206 | + | return new Response(JSON.stringify(result), { | |
| 207 | + | headers: { 'Content-Type': 'application/json' } | |
| 208 | + | }); | |
| 209 | + | }) | |
| 210 | + | }); | |
| 211 | + | ||
| 212 | + | // Remove session - for logout | |
| 213 | + | http.route({ | |
| 214 | + | path: '/api/sessions/remove', | |
| 215 | + | method: 'POST', | |
| 216 | + | handler: httpAction(async (ctx, request) => { | |
| 217 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 218 | + | ||
| 219 | + | const body = await parseJsonBody<{ tokenHash: string }>(request); | |
| 220 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 221 | + | ||
| 222 | + | await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash }); | |
| 223 | + | ||
| 224 | + | return new Response(JSON.stringify({ success: true }), { | |
| 225 | + | headers: { 'Content-Type': 'application/json' } | |
| 226 | + | }); | |
| 227 | + | }) | |
| 228 | + | }); | |
| 229 | + | ||
| 230 | + | // ============ CHAT ROOMS (server-side only) ============ | |
| 231 | + | ||
| 232 | + | // Get or create default room | |
| 233 | + | http.route({ | |
| 234 | + | path: '/api/chatRooms/getOrCreateDefault', | |
| 235 | + | method: 'POST', | |
| 236 | + | handler: httpAction(async (ctx, request) => { | |
| 237 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 238 | + | ||
| 239 | + | const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault); | |
| 240 | + | ||
| 241 | + | return new Response(JSON.stringify(room), { | |
| 242 | + | headers: { 'Content-Type': 'application/json' } | |
| 243 | + | }); | |
| 244 | + | }) | |
| 245 | + | }); | |
| 246 | + | ||
| 247 | + | // Get room by ID | |
| 248 | + | http.route({ | |
| 249 | + | path: '/api/chatRooms/getById', | |
| 250 | + | method: 'POST', | |
| 251 | + | handler: httpAction(async (ctx, request) => { | |
| 252 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 253 | + | ||
| 254 | + | const body = await parseJsonBody<{ id: string }>(request); | |
| 255 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 256 | + | ||
| 257 | + | const room = await ctx.runQuery(internal.chatRoomsInternal.getById, { | |
| 258 | + | id: body.id as Id<'chatRooms'> | |
| 259 | + | }); | |
| 260 | + | ||
| 261 | + | return new Response(JSON.stringify(room), { | |
| 262 | + | headers: { 'Content-Type': 'application/json' } | |
| 263 | + | }); | |
| 264 | + | }) | |
| 265 | + | }); | |
| 266 | + | ||
| 267 | + | // ============ MESSAGES (server-side only) ============ | |
| 268 | + | ||
| 269 | + | // Get messages by room - for initial load | |
| 270 | + | http.route({ | |
| 271 | + | path: '/api/messages/getByRoom', | |
| 272 | + | method: 'POST', | |
| 273 | + | handler: httpAction(async (ctx, request) => { | |
| 274 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 275 | + | ||
| 276 | + | const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>( | |
| 277 | + | request | |
| 278 | + | ); | |
| 279 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 280 | + | ||
| 281 | + | const result = await ctx.runQuery(internal.messagesInternal.getByRoom, { | |
| 282 | + | roomId: body.roomId as Id<'chatRooms'>, | |
| 283 | + | limit: body.limit, | |
| 284 | + | beforeTimestamp: body.beforeTimestamp | |
| 285 | + | }); | |
| 286 | + | ||
| 287 | + | return new Response(JSON.stringify(result), { | |
| 288 | + | headers: { 'Content-Type': 'application/json' } | |
| 289 | + | }); | |
| 290 | + | }) | |
| 291 | + | }); | |
| 292 | + | ||
| 293 | + | // Send message | |
| 294 | + | http.route({ | |
| 295 | + | path: '/api/messages/send', | |
| 296 | + | method: 'POST', | |
| 297 | + | handler: httpAction(async (ctx, request) => { | |
| 298 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 299 | + | ||
| 300 | + | const body = await parseJsonBody<{ | |
| 301 | + | chatRoomId: string; | |
| 302 | + | senderId: string; | |
| 303 | + | content: string; | |
| 304 | + | type: string; | |
| 305 | + | styleData?: string; | |
| 306 | + | hasFormatting?: boolean; | |
| 307 | + | timestamp?: number; | |
| 308 | + | }>(request); | |
| 309 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 310 | + | ||
| 311 | + | try { | |
| 312 | + | const message = await ctx.runMutation(internal.messagesInternal.send, { | |
| 313 | + | chatRoomId: body.chatRoomId as Id<'chatRooms'>, | |
| 314 | + | senderId: body.senderId as Id<'users'>, | |
| 315 | + | content: body.content, | |
| 316 | + | type: body.type, | |
| 317 | + | styleData: body.styleData, | |
| 318 | + | hasFormatting: body.hasFormatting, | |
| 319 | + | timestamp: body.timestamp | |
| 320 | + | }); | |
| 321 | + | return new Response(JSON.stringify(message), { | |
| 322 | + | headers: { 'Content-Type': 'application/json' } | |
| 323 | + | }); | |
| 324 | + | } catch (e) { | |
| 325 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to send message'); | |
| 326 | + | } | |
| 327 | + | }) | |
| 328 | + | }); | |
| 329 | + | ||
| 330 | + | // ============ TEXT PREFERENCES (server-side only) ============ | |
| 331 | + | ||
| 332 | + | // Get user text preferences | |
| 333 | + | http.route({ | |
| 334 | + | path: '/api/textPreferences/get', | |
| 335 | + | method: 'POST', | |
| 336 | + | handler: httpAction(async (ctx, request) => { | |
| 337 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 338 | + | ||
| 339 | + | const body = await parseJsonBody<{ userId: string }>(request); | |
| 340 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 341 | + | ||
| 342 | + | const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, { | |
| 343 | + | userId: body.userId as Id<'users'> | |
| 344 | + | }); | |
| 345 | + | ||
| 346 | + | return new Response(JSON.stringify(preferences), { | |
| 347 | + | headers: { 'Content-Type': 'application/json' } | |
| 348 | + | }); | |
| 349 | + | }) | |
| 350 | + | }); | |
| 351 | + | ||
| 352 | + | // Save user text preferences | |
| 353 | + | http.route({ | |
| 354 | + | path: '/api/textPreferences/save', | |
| 355 | + | method: 'POST', | |
| 356 | + | handler: httpAction(async (ctx, request) => { | |
| 357 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 358 | + | ||
| 359 | + | const body = await parseJsonBody<{ | |
| 360 | + | userId: string; | |
| 361 | + | defaultStyle: { | |
| 362 | + | fontFamily: string; | |
| 363 | + | fontSize: number; | |
| 364 | + | color?: string; | |
| 365 | + | gradient?: string[]; | |
| 366 | + | bold: boolean; | |
| 367 | + | italic: boolean; | |
| 368 | + | underline: boolean; | |
| 369 | + | strikethrough: boolean; | |
| 370 | + | }; | |
| 371 | + | allowFormatting: boolean; | |
| 372 | + | maxMessageLength: number; | |
| 373 | + | }>(request); | |
| 374 | + | if (!body) return errorResponse('Invalid JSON', 400); | |
| 375 | + | ||
| 376 | + | try { | |
| 377 | + | const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, { | |
| 378 | + | userId: body.userId as Id<'users'>, | |
| 379 | + | defaultStyle: body.defaultStyle, | |
| 380 | + | allowFormatting: body.allowFormatting, | |
| 381 | + | maxMessageLength: body.maxMessageLength | |
| 382 | + | }); | |
| 383 | + | return new Response(JSON.stringify({ success: true, id: result }), { | |
| 384 | + | headers: { 'Content-Type': 'application/json' } | |
| 385 | + | }); | |
| 386 | + | } catch (e) { | |
| 387 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences'); | |
| 388 | + | } | |
| 389 | + | }) | |
| 390 | + | }); | |
| 391 | + | ||
| 392 | + | export default http; | |
A src/convex/messagesInternal.ts +91 -0
| @@ -0,0 +1,91 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get messages by chat room ID (internal) | |
| 5 | + | export const getByRoom = internalQuery({ | |
| 6 | + | args: { | |
| 7 | + | roomId: v.id('chatRooms'), | |
| 8 | + | limit: v.optional(v.number()), | |
| 9 | + | beforeTimestamp: v.optional(v.number()) | |
| 10 | + | }, | |
| 11 | + | handler: async (ctx, args) => { | |
| 12 | + | const limit = args.limit ?? 100; | |
| 13 | + | ||
| 14 | + | const allMessages = await ctx.db | |
| 15 | + | .query('messages') | |
| 16 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 17 | + | .collect(); | |
| 18 | + | ||
| 19 | + | // Filter by timestamp if provided | |
| 20 | + | let filtered = allMessages; | |
| 21 | + | if (args.beforeTimestamp) { | |
| 22 | + | filtered = allMessages.filter((m) => m.timestamp < args.beforeTimestamp!); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Sort by timestamp descending and limit | |
| 26 | + | filtered.sort((a, b) => b.timestamp - a.timestamp); | |
| 27 | + | const messages = filtered.slice(0, limit); | |
| 28 | + | ||
| 29 | + | return { | |
| 30 | + | messages, | |
| 31 | + | hasMore: filtered.length > limit | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | // Send message (internal) | |
| 37 | + | export const send = internalMutation({ | |
| 38 | + | args: { | |
| 39 | + | chatRoomId: v.id('chatRooms'), | |
| 40 | + | senderId: v.id('users'), | |
| 41 | + | content: v.string(), | |
| 42 | + | type: v.optional(v.string()), | |
| 43 | + | styleData: v.optional(v.string()), | |
| 44 | + | hasFormatting: v.optional(v.boolean()), | |
| 45 | + | timestamp: v.optional(v.number()) | |
| 46 | + | }, | |
| 47 | + | handler: async (ctx, args) => { | |
| 48 | + | // Verify room exists | |
| 49 | + | const room = await ctx.db.get(args.chatRoomId); | |
| 50 | + | if (!room) { | |
| 51 | + | throw new Error('Chat room not found'); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // Verify user exists | |
| 55 | + | const user = await ctx.db.get(args.senderId); | |
| 56 | + | if (!user) { | |
| 57 | + | throw new Error('User not found'); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | const messageId = await ctx.db.insert('messages', { | |
| 61 | + | chatRoomId: args.chatRoomId, | |
| 62 | + | senderId: args.senderId, | |
| 63 | + | content: args.content, | |
| 64 | + | type: args.type ?? 'chat', | |
| 65 | + | timestamp: args.timestamp ?? Date.now(), | |
| 66 | + | styleData: args.styleData, | |
| 67 | + | hasFormatting: args.hasFormatting ?? false | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | return await ctx.db.get(messageId); | |
| 71 | + | } | |
| 72 | + | }); | |
| 73 | + | ||
| 74 | + | // Get recent messages across all rooms (internal) - for SSE polling | |
| 75 | + | export const getRecent = internalQuery({ | |
| 76 | + | args: { | |
| 77 | + | sinceTimestamp: v.number(), | |
| 78 | + | limit: v.optional(v.number()) | |
| 79 | + | }, | |
| 80 | + | handler: async (ctx, args) => { | |
| 81 | + | const limit = args.limit ?? 50; | |
| 82 | + | ||
| 83 | + | const messages = await ctx.db | |
| 84 | + | .query('messages') | |
| 85 | + | .withIndex('by_timestamp') | |
| 86 | + | .filter((q) => q.gt(q.field('timestamp'), args.sinceTimestamp)) | |
| 87 | + | .take(limit); | |
| 88 | + | ||
| 89 | + | return messages; | |
| 90 | + | } | |
| 91 | + | }); | |
A src/convex/mutations.ts +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | import { mutation } from './_generated/server'; | |
| 2 | + | import { authMutation } from './auth'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | ||
| 5 | + | // Authenticated mutation: Send a message | |
| 6 | + | export const sendMessage = authMutation({ | |
| 7 | + | args: { | |
| 8 | + | chatRoomId: v.id('chatRooms'), | |
| 9 | + | content: v.string(), | |
| 10 | + | type: v.optional(v.string()), | |
| 11 | + | styleData: v.optional(v.string()), | |
| 12 | + | hasFormatting: v.optional(v.boolean()) | |
| 13 | + | }, | |
| 14 | + | handler: async (ctx, args) => { | |
| 15 | + | // ctx.user is provided by authMutation | |
| 16 | + | const user = ctx.user; | |
| 17 | + | ||
| 18 | + | // Verify room exists | |
| 19 | + | const room = await ctx.db.get(args.chatRoomId); | |
| 20 | + | if (!room) { | |
| 21 | + | throw new Error('Chat room not found'); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | const messageId = await ctx.db.insert('messages', { | |
| 25 | + | chatRoomId: args.chatRoomId, | |
| 26 | + | senderId: user._id, | |
| 27 | + | content: args.content, | |
| 28 | + | type: args.type ?? 'chat', | |
| 29 | + | timestamp: Date.now(), | |
| 30 | + | styleData: args.styleData, | |
| 31 | + | hasFormatting: args.hasFormatting ?? false | |
| 32 | + | }); | |
| 33 | + | ||
| 34 | + | const message = await ctx.db.get(messageId); | |
| 35 | + | ||
| 36 | + | // Return enriched message | |
| 37 | + | return { | |
| 38 | + | id: message!._id, | |
| 39 | + | chatRoomId: message!.chatRoomId, | |
| 40 | + | senderId: message!.senderId, | |
| 41 | + | content: message!.content, | |
| 42 | + | type: message!.type, | |
| 43 | + | timestamp: message!.timestamp, | |
| 44 | + | styleData: message!.styleData, | |
| 45 | + | hasFormatting: message!.hasFormatting, | |
| 46 | + | sender: { | |
| 47 | + | id: user._id, | |
| 48 | + | nickname: user.nickname, | |
| 49 | + | status: user.status, | |
| 50 | + | avatarUrl: user.avatarUrl | |
| 51 | + | } | |
| 52 | + | }; | |
| 53 | + | } | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | // Authenticated mutation: Update current user's status | |
| 57 | + | export const updateMyStatus = authMutation({ | |
| 58 | + | args: { | |
| 59 | + | status: v.string() | |
| 60 | + | }, | |
| 61 | + | handler: async (ctx, args) => { | |
| 62 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |
| 63 | + | if (!validStatuses.includes(args.status)) { | |
| 64 | + | throw new Error('Invalid status'); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | await ctx.db.patch(ctx.user._id, { | |
| 68 | + | status: args.status, | |
| 69 | + | lastSeen: Date.now() | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | const updated = await ctx.db.get(ctx.user._id); | |
| 73 | + | return { | |
| 74 | + | id: updated!._id, | |
| 75 | + | nickname: updated!.nickname, | |
| 76 | + | status: updated!.status, | |
| 77 | + | avatarUrl: updated!.avatarUrl, | |
| 78 | + | lastSeen: updated!.lastSeen | |
| 79 | + | }; | |
| 80 | + | } | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | // Public mutation: Create or get default room (can be called during init) | |
| 84 | + | export const getOrCreateDefaultRoom = mutation({ | |
| 85 | + | args: {}, | |
| 86 | + | handler: async (ctx) => { | |
| 87 | + | // Look for existing General room | |
| 88 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 89 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 90 | + | ||
| 91 | + | if (generalRoom) { | |
| 92 | + | return { | |
| 93 | + | id: generalRoom._id, | |
| 94 | + | name: generalRoom.name, | |
| 95 | + | type: generalRoom.type, | |
| 96 | + | createdAt: generalRoom.createdAt | |
| 97 | + | }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Create default room | |
| 101 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 102 | + | name: 'General', | |
| 103 | + | type: 'group', | |
| 104 | + | createdAt: Date.now() | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | const room = await ctx.db.get(roomId); | |
| 108 | + | return { | |
| 109 | + | id: room!._id, | |
| 110 | + | name: room!.name, | |
| 111 | + | type: room!.type, | |
| 112 | + | createdAt: room!.createdAt | |
| 113 | + | }; | |
| 114 | + | } | |
| 115 | + | }); | |
A src/convex/queries.ts +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | import { query } from './_generated/server'; | |
| 2 | + | import { authQuery } from './auth'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | ||
| 5 | + | // ============ PUBLIC QUERIES (no auth required) ============ | |
| 6 | + | ||
| 7 | + | // Public query: Get messages for a chat room (real-time subscription) | |
| 8 | + | export const getMessagesPublic = query({ | |
| 9 | + | args: { | |
| 10 | + | roomId: v.id('chatRooms'), | |
| 11 | + | limit: v.optional(v.number()) | |
| 12 | + | }, | |
| 13 | + | handler: async (ctx, args) => { | |
| 14 | + | const limit = args.limit ?? 50; | |
| 15 | + | ||
| 16 | + | const messages = await ctx.db | |
| 17 | + | .query('messages') | |
| 18 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 19 | + | .order('desc') | |
| 20 | + | .take(limit); | |
| 21 | + | ||
| 22 | + | // Enrich messages with sender info | |
| 23 | + | const enrichedMessages = await Promise.all( | |
| 24 | + | messages.map(async (msg) => { | |
| 25 | + | const sender = await ctx.db.get(msg.senderId); | |
| 26 | + | return { | |
| 27 | + | id: msg._id, | |
| 28 | + | chatRoomId: msg.chatRoomId, | |
| 29 | + | senderId: msg.senderId, | |
| 30 | + | content: msg.content, | |
| 31 | + | type: msg.type, | |
| 32 | + | timestamp: msg.timestamp, | |
| 33 | + | styleData: msg.styleData, | |
| 34 | + | hasFormatting: msg.hasFormatting, | |
| 35 | + | sender: sender | |
| 36 | + | ? { | |
| 37 | + | id: sender._id, | |
| 38 | + | nickname: sender.nickname, | |
| 39 | + | status: sender.status, | |
| 40 | + | avatarUrl: sender.avatarUrl | |
| 41 | + | } | |
| 42 | + | : null | |
| 43 | + | }; | |
| 44 | + | }) | |
| 45 | + | ); | |
| 46 | + | ||
| 47 | + | // Return in chronological order (oldest first) | |
| 48 | + | return enrichedMessages.reverse(); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | // Public query: Get all users (real-time subscription for buddy list) | |
| 53 | + | export const getUsersPublic = query({ | |
| 54 | + | args: {}, | |
| 55 | + | handler: async (ctx) => { | |
| 56 | + | const users = await ctx.db.query('users').collect(); | |
| 57 | + | ||
| 58 | + | // Return safe user data (no passwords) | |
| 59 | + | return users.map((user) => ({ | |
| 60 | + | id: user._id, | |
| 61 | + | nickname: user.nickname, | |
| 62 | + | status: user.status, | |
| 63 | + | avatarUrl: user.avatarUrl, | |
| 64 | + | lastSeen: user.lastSeen | |
| 65 | + | })); | |
| 66 | + | } | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | // Public query: Get default room | |
| 70 | + | export const getDefaultRoomPublic = query({ | |
| 71 | + | args: {}, | |
| 72 | + | handler: async (ctx) => { | |
| 73 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 74 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 75 | + | ||
| 76 | + | if (defaultRoom) { | |
| 77 | + | return { | |
| 78 | + | id: defaultRoom._id, | |
| 79 | + | name: defaultRoom.name, | |
| 80 | + | type: defaultRoom.type, | |
| 81 | + | createdAt: defaultRoom.createdAt | |
| 82 | + | }; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Return first group room if General doesn't exist | |
| 86 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |
| 87 | + | if (firstGroup) { | |
| 88 | + | return { | |
| 89 | + | id: firstGroup._id, | |
| 90 | + | name: firstGroup.name, | |
| 91 | + | type: firstGroup.type, | |
| 92 | + | createdAt: firstGroup.createdAt | |
| 93 | + | }; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | return null; | |
| 97 | + | } | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | // ============ AUTHENTICATED QUERIES ============ | |
| 101 | + | ||
| 102 | + | // Authenticated query: Get messages for a chat room (real-time subscription) | |
| 103 | + | export const getMessages = authQuery({ | |
| 104 | + | args: { | |
| 105 | + | roomId: v.id('chatRooms'), | |
| 106 | + | limit: v.optional(v.number()) | |
| 107 | + | }, | |
| 108 | + | handler: async (ctx, args) => { | |
| 109 | + | const limit = args.limit ?? 100; | |
| 110 | + | ||
| 111 | + | const messages = await ctx.db | |
| 112 | + | .query('messages') | |
| 113 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 114 | + | .order('desc') | |
| 115 | + | .take(limit); | |
| 116 | + | ||
| 117 | + | // Enrich messages with sender info | |
| 118 | + | const enrichedMessages = await Promise.all( | |
| 119 | + | messages.map(async (msg) => { | |
| 120 | + | const sender = await ctx.db.get(msg.senderId); | |
| 121 | + | return { | |
| 122 | + | id: msg._id, | |
| 123 | + | chatRoomId: msg.chatRoomId, | |
| 124 | + | senderId: msg.senderId, | |
| 125 | + | content: msg.content, | |
| 126 | + | type: msg.type, | |
| 127 | + | timestamp: msg.timestamp, | |
| 128 | + | styleData: msg.styleData, | |
| 129 | + | hasFormatting: msg.hasFormatting, | |
| 130 | + | sender: sender | |
| 131 | + | ? { | |
| 132 | + | id: sender._id, | |
| 133 | + | nickname: sender.nickname, | |
| 134 | + | status: sender.status, | |
| 135 | + | avatarUrl: sender.avatarUrl | |
| 136 | + | } | |
| 137 | + | : null | |
| 138 | + | }; | |
| 139 | + | }) | |
| 140 | + | ); | |
| 141 | + | ||
| 142 | + | // Return in chronological order (oldest first) | |
| 143 | + | return enrichedMessages.reverse(); | |
| 144 | + | } | |
| 145 | + | }); | |
| 146 | + | ||
| 147 | + | // Authenticated query: Get all users (real-time subscription for buddy list) | |
| 148 | + | export const getUsers = authQuery({ | |
| 149 | + | args: {}, | |
| 150 | + | handler: async (ctx) => { | |
| 151 | + | const users = await ctx.db.query('users').collect(); | |
| 152 | + | ||
| 153 | + | // Return safe user data (no passwords) | |
| 154 | + | return users.map((user) => ({ | |
| 155 | + | id: user._id, | |
| 156 | + | nickname: user.nickname, | |
| 157 | + | status: user.status, | |
| 158 | + | avatarUrl: user.avatarUrl, | |
| 159 | + | lastSeen: user.lastSeen | |
| 160 | + | })); | |
| 161 | + | } | |
| 162 | + | }); | |
| 163 | + | ||
| 164 | + | // Authenticated query: Get chat rooms | |
| 165 | + | export const getChatRooms = authQuery({ | |
| 166 | + | args: {}, | |
| 167 | + | handler: async (ctx) => { | |
| 168 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 169 | + | return rooms.map((room) => ({ | |
| 170 | + | id: room._id, | |
| 171 | + | name: room.name, | |
| 172 | + | type: room.type, | |
| 173 | + | createdAt: room.createdAt | |
| 174 | + | })); | |
| 175 | + | } | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | // Authenticated query: Get default room (or first group room) | |
| 179 | + | export const getDefaultRoom = authQuery({ | |
| 180 | + | args: {}, | |
| 181 | + | handler: async (ctx) => { | |
| 182 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 183 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 184 | + | ||
| 185 | + | if (defaultRoom) { | |
| 186 | + | return { | |
| 187 | + | id: defaultRoom._id, | |
| 188 | + | name: defaultRoom.name, | |
| 189 | + | type: defaultRoom.type, | |
| 190 | + | createdAt: defaultRoom.createdAt | |
| 191 | + | }; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Return first group room if General doesn't exist | |
| 195 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |
| 196 | + | if (firstGroup) { | |
| 197 | + | return { | |
| 198 | + | id: firstGroup._id, | |
| 199 | + | name: firstGroup.name, | |
| 200 | + | type: firstGroup.type, | |
| 201 | + | createdAt: firstGroup.createdAt | |
| 202 | + | }; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | return null; | |
| 206 | + | } | |
| 207 | + | }); | |
| 208 | + | ||
| 209 | + | // Authenticated query: Get current user info | |
| 210 | + | export const getCurrentUser = authQuery({ | |
| 211 | + | args: {}, | |
| 212 | + | handler: async (ctx) => { | |
| 213 | + | // ctx.user is provided by authQuery | |
| 214 | + | return { | |
| 215 | + | id: ctx.user._id, | |
| 216 | + | nickname: ctx.user.nickname, | |
| 217 | + | status: ctx.user.status, | |
| 218 | + | avatarUrl: ctx.user.avatarUrl, | |
| 219 | + | lastSeen: ctx.user.lastSeen | |
| 220 | + | }; | |
| 221 | + | } | |
| 222 | + | }); | |
A src/convex/schema.ts +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | import { defineSchema, defineTable } from 'convex/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | export default defineSchema({ | |
| 5 | + | users: defineTable({ | |
| 6 | + | password: v.string(), | |
| 7 | + | nickname: v.string(), | |
| 8 | + | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | |
| 9 | + | avatarUrl: v.optional(v.string()), | |
| 10 | + | createdAt: v.number(), | |
| 11 | + | lastSeen: v.optional(v.number()) | |
| 12 | + | }).index('by_nickname', ['nickname']), | |
| 13 | + | ||
| 14 | + | sessions: defineTable({ | |
| 15 | + | tokenHash: v.string(), // SHA-256 hash of the session token | |
| 16 | + | userId: v.id('users'), | |
| 17 | + | expiresAt: v.number(), | |
| 18 | + | createdAt: v.number() | |
| 19 | + | }) | |
| 20 | + | .index('by_tokenHash', ['tokenHash']) | |
| 21 | + | .index('by_userId', ['userId']), | |
| 22 | + | ||
| 23 | + | chatRooms: defineTable({ | |
| 24 | + | name: v.optional(v.string()), | |
| 25 | + | type: v.string(), // 'direct' | 'group' | |
| 26 | + | createdAt: v.number() | |
| 27 | + | }), | |
| 28 | + | ||
| 29 | + | messages: defineTable({ | |
| 30 | + | chatRoomId: v.id('chatRooms'), | |
| 31 | + | senderId: v.id('users'), | |
| 32 | + | content: v.string(), | |
| 33 | + | type: v.string(), // 'chat' | 'emote' | 'system' | |
| 34 | + | timestamp: v.number(), | |
| 35 | + | styleData: v.optional(v.string()), | |
| 36 | + | hasFormatting: v.optional(v.boolean()) | |
| 37 | + | }) | |
| 38 | + | .index('by_chatRoom', ['chatRoomId']) | |
| 39 | + | .index('by_timestamp', ['timestamp']), | |
| 40 | + | ||
| 41 | + | userTextPreferences: defineTable({ | |
| 42 | + | userId: v.id('users'), | |
| 43 | + | defaultStyle: v.object({ | |
| 44 | + | fontFamily: v.string(), | |
| 45 | + | fontSize: v.number(), | |
| 46 | + | color: v.optional(v.string()), | |
| 47 | + | gradient: v.optional(v.array(v.string())), | |
| 48 | + | bold: v.boolean(), | |
| 49 | + | italic: v.boolean(), | |
| 50 | + | underline: v.boolean(), | |
| 51 | + | strikethrough: v.boolean() | |
| 52 | + | }), | |
| 53 | + | allowFormatting: v.boolean(), | |
| 54 | + | maxMessageLength: v.number(), | |
| 55 | + | createdAt: v.number(), | |
| 56 | + | updatedAt: v.number() | |
| 57 | + | }).index('by_userId', ['userId']) | |
| 58 | + | }); | |
A src/convex/sessionsInternal.ts +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Create a new session (internal) | |
| 5 | + | export const create = internalMutation({ | |
| 6 | + | args: { | |
| 7 | + | tokenHash: v.string(), // SHA-256 hash of the token | |
| 8 | + | userId: v.id('users'), | |
| 9 | + | expiresAt: v.number() | |
| 10 | + | }, | |
| 11 | + | handler: async (ctx, args) => { | |
| 12 | + | const user = await ctx.db.get(args.userId); | |
| 13 | + | if (!user) { | |
| 14 | + | throw new Error('User not found'); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | const id = await ctx.db.insert('sessions', { | |
| 18 | + | tokenHash: args.tokenHash, | |
| 19 | + | userId: args.userId, | |
| 20 | + | expiresAt: args.expiresAt, | |
| 21 | + | createdAt: Date.now() | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | return { id, tokenHash: args.tokenHash }; | |
| 25 | + | } | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | // Validate session and get user (internal) | |
| 29 | + | export const validateAndGetUser = internalQuery({ | |
| 30 | + | args: { tokenHash: v.string() }, | |
| 31 | + | handler: async (ctx, args) => { | |
| 32 | + | // Find session by token hash | |
| 33 | + | const session = await ctx.db | |
| 34 | + | .query('sessions') | |
| 35 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |
| 36 | + | .first(); | |
| 37 | + | ||
| 38 | + | if (!session) { | |
| 39 | + | return { session: null, user: null }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Check if expired | |
| 43 | + | if (session.expiresAt < Date.now()) { | |
| 44 | + | return { session: null, user: null }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Get the user | |
| 48 | + | const user = await ctx.db.get(session.userId); | |
| 49 | + | if (!user) { | |
| 50 | + | return { session: null, user: null }; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | return { session, user }; | |
| 54 | + | } | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | // Delete a session by token hash (internal) | |
| 58 | + | export const remove = internalMutation({ | |
| 59 | + | args: { tokenHash: v.string() }, | |
| 60 | + | handler: async (ctx, args) => { | |
| 61 | + | const session = await ctx.db | |
| 62 | + | .query('sessions') | |
| 63 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |
| 64 | + | .first(); | |
| 65 | + | ||
| 66 | + | if (session) { | |
| 67 | + | await ctx.db.delete(session._id); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | // Delete all sessions for a user (internal) | |
| 73 | + | export const removeAllForUser = internalMutation({ | |
| 74 | + | args: { userId: v.id('users') }, | |
| 75 | + | handler: async (ctx, args) => { | |
| 76 | + | const sessions = await ctx.db | |
| 77 | + | .query('sessions') | |
| 78 | + | .withIndex('by_userId', (q) => q.eq('userId', args.userId)) | |
| 79 | + | .collect(); | |
| 80 | + | ||
| 81 | + | for (const session of sessions) { | |
| 82 | + | await ctx.db.delete(session._id); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return { deleted: sessions.length }; | |
| 86 | + | } | |
| 87 | + | }); | |
A src/convex/textPreferencesInternal.ts +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get user text preferences by userId | |
| 5 | + | export const getByUserId = internalQuery({ | |
| 6 | + | args: { userId: v.id('users') }, | |
| 7 | + | handler: async (ctx, { userId }) => { | |
| 8 | + | const preferences = await ctx.db | |
| 9 | + | .query('userTextPreferences') | |
| 10 | + | .withIndex('by_userId', (q) => q.eq('userId', userId)) | |
| 11 | + | .first(); | |
| 12 | + | ||
| 13 | + | return preferences; | |
| 14 | + | } | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | // Upsert user text preferences (create or update) | |
| 18 | + | export const upsert = internalMutation({ | |
| 19 | + | args: { | |
| 20 | + | userId: v.id('users'), | |
| 21 | + | defaultStyle: v.object({ | |
| 22 | + | fontFamily: v.string(), | |
| 23 | + | fontSize: v.number(), | |
| 24 | + | color: v.optional(v.string()), | |
| 25 | + | gradient: v.optional(v.array(v.string())), | |
| 26 | + | bold: v.boolean(), | |
| 27 | + | italic: v.boolean(), | |
| 28 | + | underline: v.boolean(), | |
| 29 | + | strikethrough: v.boolean() | |
| 30 | + | }), | |
| 31 | + | allowFormatting: v.boolean(), | |
| 32 | + | maxMessageLength: v.number() | |
| 33 | + | }, | |
| 34 | + | handler: async (ctx, args) => { | |
| 35 | + | const now = Date.now(); | |
| 36 | + | ||
| 37 | + | // Check if preferences already exist | |
| 38 | + | const existing = await ctx.db | |
| 39 | + | .query('userTextPreferences') | |
| 40 | + | .withIndex('by_userId', (q) => q.eq('userId', args.userId)) | |
| 41 | + | .first(); | |
| 42 | + | ||
| 43 | + | if (existing) { | |
| 44 | + | // Update existing preferences | |
| 45 | + | return await ctx.db.patch(existing._id, { | |
| 46 | + | defaultStyle: args.defaultStyle, | |
| 47 | + | allowFormatting: args.allowFormatting, | |
| 48 | + | maxMessageLength: args.maxMessageLength, | |
| 49 | + | updatedAt: now | |
| 50 | + | }); | |
| 51 | + | } else { | |
| 52 | + | // Create new preferences | |
| 53 | + | return await ctx.db.insert('userTextPreferences', { | |
| 54 | + | userId: args.userId, | |
| 55 | + | defaultStyle: args.defaultStyle, | |
| 56 | + | allowFormatting: args.allowFormatting, | |
| 57 | + | maxMessageLength: args.maxMessageLength, | |
| 58 | + | createdAt: now, | |
| 59 | + | updatedAt: now | |
| 60 | + | }); | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | }); | |
A src/convex/tsconfig.json +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | { | |
| 2 | + | /* This TypeScript project config describes the environment that | |
| 3 | + | * Convex functions run in and is used to typecheck them. | |
| 4 | + | * You can modify it, but some settings are required to use Convex. | |
| 5 | + | */ | |
| 6 | + | "compilerOptions": { | |
| 7 | + | /* These settings are not required by Convex and can be modified. */ | |
| 8 | + | "allowJs": true, | |
| 9 | + | "strict": true, | |
| 10 | + | "moduleResolution": "Bundler", | |
| 11 | + | "jsx": "react-jsx", | |
| 12 | + | "skipLibCheck": true, | |
| 13 | + | "allowSyntheticDefaultImports": true, | |
| 14 | + | ||
| 15 | + | /* These compiler options are required by Convex */ | |
| 16 | + | "target": "ESNext", | |
| 17 | + | "lib": ["ES2021", "dom"], | |
| 18 | + | "forceConsistentCasingInFileNames": true, | |
| 19 | + | "module": "ESNext", | |
| 20 | + | "isolatedModules": true, | |
| 21 | + | "noEmit": true | |
| 22 | + | }, | |
| 23 | + | "include": ["./**/*"], | |
| 24 | + | "exclude": ["./_generated"] | |
| 25 | + | } | |
A src/convex/usersInternal.ts +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get user by nickname (internal) | |
| 5 | + | export const getByNickname = internalQuery({ | |
| 6 | + | args: { nickname: v.string() }, | |
| 7 | + | handler: async (ctx, args) => { | |
| 8 | + | return await ctx.db | |
| 9 | + | .query('users') | |
| 10 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |
| 11 | + | .first(); | |
| 12 | + | } | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | // Get user by ID (internal) | |
| 16 | + | export const getById = internalQuery({ | |
| 17 | + | args: { id: v.id('users') }, | |
| 18 | + | handler: async (ctx, args) => { | |
| 19 | + | return await ctx.db.get(args.id); | |
| 20 | + | } | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | // Get all users (internal) | |
| 24 | + | export const list = internalQuery({ | |
| 25 | + | args: {}, | |
| 26 | + | handler: async (ctx) => { | |
| 27 | + | return await ctx.db.query('users').collect(); | |
| 28 | + | } | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | // Create a new user (internal) | |
| 32 | + | export const create = internalMutation({ | |
| 33 | + | args: { | |
| 34 | + | nickname: v.string(), | |
| 35 | + | password: v.string() | |
| 36 | + | }, | |
| 37 | + | handler: async (ctx, args) => { | |
| 38 | + | // Check if nickname already exists | |
| 39 | + | const existing = await ctx.db | |
| 40 | + | .query('users') | |
| 41 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |
| 42 | + | .first(); | |
| 43 | + | ||
| 44 | + | if (existing) { | |
| 45 | + | throw new Error('Username already taken'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | const userId = await ctx.db.insert('users', { | |
| 49 | + | nickname: args.nickname, | |
| 50 | + | password: args.password, | |
| 51 | + | status: 'offline', | |
| 52 | + | createdAt: Date.now() | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | return userId; | |
| 56 | + | } | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | // Update user status by ID (internal) | |
| 60 | + | export const updateStatus = internalMutation({ | |
| 61 | + | args: { | |
| 62 | + | id: v.id('users'), | |
| 63 | + | status: v.string() | |
| 64 | + | }, | |
| 65 | + | handler: async (ctx, args) => { | |
| 66 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |
| 67 | + | if (!validStatuses.includes(args.status)) { | |
| 68 | + | throw new Error('Invalid status'); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | const user = await ctx.db.get(args.id); | |
| 72 | + | if (!user) { | |
| 73 | + | throw new Error('User not found'); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | await ctx.db.patch(args.id, { | |
| 77 | + | status: args.status, | |
| 78 | + | lastSeen: Date.now() | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | return await ctx.db.get(args.id); | |
| 82 | + | } | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | // Set all users offline (internal) - used on server startup | |
| 86 | + | export const setAllOffline = internalMutation({ | |
| 87 | + | args: {}, | |
| 88 | + | handler: async (ctx) => { | |
| 89 | + | const users = await ctx.db.query('users').collect(); | |
| 90 | + | for (const user of users) { | |
| 91 | + | await ctx.db.patch(user._id, { status: 'offline' }); | |
| 92 | + | } | |
| 93 | + | return { updated: users.length }; | |
| 94 | + | } | |
| 95 | + | }); | |
M src/hooks.server.ts +28 -6
| @@ -1,7 +1,6 @@ | |||
| 1 | 1 | import type { Handle } from '@sveltejs/kit'; | |
| 2 | - | import db from '$lib/db/db.server'; | |
| 2 | + | import { users } from '$lib/db/convex.server'; | |
| 3 | 3 | import { handleRateLimit } from '$lib/api/rate-limiter'; | |
| 4 | - | import { users } from '$lib/db/schema'; | |
| 5 | 4 | import { createLogger } from '$lib/utils/logger.server'; | |
| 6 | 5 | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | |
| 7 | 6 | import { validateSessionToken } from '$lib/api/session.server'; | |
| @@ -24,11 +23,11 @@ function addSecurityHeaders(response: Response): Response { | |||
| 24 | 23 | }); | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 27 | - | // Set all users to offline on server start | |
| 26 | + | // Set all users to offline on server start (using Convex) | |
| 28 | 27 | async function setAllUsersOffline() { | |
| 29 | 28 | try { | |
| 30 | - | await db.update(users).set({ status: 'offline' }); | |
| 31 | - | log.info('All users set to offline on server start'); | |
| 29 | + | const result = await users.setAllOffline(); | |
| 30 | + | log.info('All users set to offline on server start', { updated: result.updated }); | |
| 32 | 31 | } catch (error: unknown) { | |
| 33 | 32 | log.error('Failed to set users offline on server start:', { error }); | |
| 34 | 33 | } | |
| @@ -38,7 +37,7 @@ async function setAllUsersOffline() { | |||
| 38 | 37 | Promise.resolve() | |
| 39 | 38 | .then(async () => { | |
| 40 | 39 | log.info('Initializing server...'); | |
| 41 | - | await ensureDefaultChatRoom(db); | |
| 40 | + | await ensureDefaultChatRoom(); | |
| 42 | 41 | await setAllUsersOffline(); | |
| 43 | 42 | log.info('Server initialized successfully'); | |
| 44 | 43 | }) | |
| @@ -50,6 +49,29 @@ Promise.resolve() | |||
| 50 | 49 | export const handle: Handle = async ({ event, resolve }) => { | |
| 51 | 50 | log.debug('Handling request', { path: event.url.pathname }); | |
| 52 | 51 | ||
| 52 | + | // CSRF protection: validate Origin header for state-changing requests | |
| 53 | + | if (event.request.method !== 'GET' && event.request.method !== 'HEAD') { | |
| 54 | + | const origin = event.request.headers.get('origin'); | |
| 55 | + | const host = event.request.headers.get('host'); | |
| 56 | + | ||
| 57 | + | // Block cross-origin requests (origin header is present but doesn't match host) | |
| 58 | + | if (origin) { | |
| 59 | + | try { | |
| 60 | + | const originUrl = new URL(origin); | |
| 61 | + | if (originUrl.host !== host) { | |
| 62 | + | log.warn('CSRF check failed - origin mismatch', { | |
| 63 | + | origin: originUrl.host, | |
| 64 | + | host | |
| 65 | + | }); | |
| 66 | + | return new Response('Forbidden', { status: 403 }); | |
| 67 | + | } | |
| 68 | + | } catch { | |
| 69 | + | log.warn('CSRF check failed - invalid origin', { origin }); | |
| 70 | + | return new Response('Forbidden', { status: 403 }); | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 53 | 75 | // Get session token from cookies | |
| 54 | 76 | const token = event.cookies.get('session') ?? null; | |
| 55 | 77 | ||
M src/lib/api/session.server.ts +39 -48
| @@ -1,7 +1,5 @@ | |||
| 1 | - | import db from '../db/db.server'; | |
| 2 | - | import { sessions, users } from '../db/schema'; | |
| 3 | - | import { eq } from 'drizzle-orm'; | |
| 4 | - | import type { Session, User } from '../types/chat'; | |
| 1 | + | import { sessions } from '../db/convex.server'; | |
| 2 | + | import type { Session, SafeUser } from '../types/chat'; | |
| 5 | 3 | import { createLogger } from '../utils/logger.server'; | |
| 6 | 4 | ||
| 7 | 5 | const log = createLogger('session-server'); | |
| @@ -17,7 +15,6 @@ export async function sha256(message: string): Promise<string> { | |||
| 17 | 15 | } | |
| 18 | 16 | ||
| 19 | 17 | // Helper: Custom Base32 encoder using the standard alphabet. | |
| 20 | - | // We use this instead of @oslojs/encoding given Cloudflare Pages compatibility. | |
| 21 | 18 | const base32Alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | |
| 22 | 19 | function base32Encode(data: Uint8Array): string { | |
| 23 | 20 | let bits = 0; | |
| @@ -44,7 +41,7 @@ export function generateSessionToken(): string { | |||
| 44 | 41 | const randomBytesArray = new Uint8Array(20); | |
| 45 | 42 | crypto.getRandomValues(randomBytesArray); | |
| 46 | 43 | const token = base32Encode(randomBytesArray); | |
| 47 | - | log.debug('Generated session token', { token }); | |
| 44 | + | log.debug('Generated session token', { tokenPrefix: token.slice(0, 4) + '***' }); | |
| 48 | 45 | return token; | |
| 49 | 46 | } | |
| 50 | 47 | ||
| @@ -52,65 +49,59 @@ export function generateSessionToken(): string { | |||
| 52 | 49 | // The session ID is the SHA-256 hash of the token. | |
| 53 | 50 | export async function createSession(token: string, userId: string): Promise<Session> { | |
| 54 | 51 | log.debug('Creating session', { userId }); | |
| 55 | - | const sessionId = await sha256(token); | |
| 52 | + | const tokenHash = await sha256(token); | |
| 56 | 53 | const createdAt = Date.now(); | |
| 57 | 54 | const expiresAt = createdAt + 7 * 24 * 60 * 60 * 1000; // 7 days expiry | |
| 58 | - | await db.insert(sessions).values({ | |
| 59 | - | id: sessionId, | |
| 60 | - | userId, | |
| 61 | - | createdAt, | |
| 62 | - | expiresAt | |
| 63 | - | }); | |
| 64 | - | log.debug('Session created', { sessionId }); | |
| 65 | - | return { id: sessionId, userId, createdAt, expiresAt }; | |
| 55 | + | ||
| 56 | + | await sessions.create(tokenHash, userId, expiresAt); | |
| 57 | + | ||
| 58 | + | log.debug('Session created', { tokenHash }); | |
| 59 | + | return { id: tokenHash, userId, createdAt, expiresAt }; | |
| 66 | 60 | } | |
| 67 | 61 | ||
| 68 | 62 | /** | |
| 69 | 63 | * Type distributed through our API. | |
| 70 | 64 | * Returns an object with both session and user if valid, or nulls if not. | |
| 71 | - | * Note: This returns the full User type since it's used server-side in hooks. | |
| 72 | 65 | */ | |
| 73 | 66 | export type SessionValidationResult = | |
| 74 | - | | { session: Session; user: User } | |
| 67 | + | | { session: Session; user: SafeUser } | |
| 75 | 68 | | { session: null; user: null }; | |
| 76 | 69 | ||
| 77 | - | // Validate a session token by converting it to its SHA‑256 hash, checking expiration, and fetching the user. | |
| 70 | + | // Validate a session token by converting it to its SHA-256 hash, checking expiration, and fetching the user. | |
| 78 | 71 | export async function validateSessionToken(token: string): Promise<SessionValidationResult> { | |
| 79 | 72 | log.debug('Validating session token'); | |
| 80 | - | const sessionId = await sha256(token); | |
| 81 | - | const session = await db.query.sessions.findFirst({ | |
| 82 | - | where: eq(sessions.id, sessionId) | |
| 83 | - | }); | |
| 84 | - | if (!session) { | |
| 85 | - | log.debug('Session not found', { sessionId }); | |
| 86 | - | return { session: null, user: null }; | |
| 87 | - | } | |
| 88 | - | const now = Date.now(); | |
| 89 | - | if (session.expiresAt < now) { | |
| 90 | - | log.debug('Session expired', { sessionId }); | |
| 91 | - | return { session: null, user: null }; | |
| 92 | - | } | |
| 93 | - | const user = await db.query.users.findFirst({ | |
| 94 | - | where: eq(users.id, session.userId) | |
| 95 | - | }); | |
| 96 | - | if (!user) { | |
| 97 | - | log.debug('User not found for session', { sessionId }); | |
| 73 | + | const tokenHash = await sha256(token); | |
| 74 | + | ||
| 75 | + | const result = await sessions.validate(tokenHash); | |
| 76 | + | ||
| 77 | + | if (!result.session || !result.user) { | |
| 78 | + | log.debug('Session not found or invalid', { tokenHash }); | |
| 98 | 79 | return { session: null, user: null }; | |
| 99 | 80 | } | |
| 100 | - | log.debug('Session validated', { sessionId, userId: user.id }); | |
| 81 | + | ||
| 82 | + | // Map Convex types to our local types - using native Convex IDs | |
| 83 | + | const session: Session = { | |
| 84 | + | id: result.session.tokenHash, | |
| 85 | + | userId: result.user._id, | |
| 86 | + | createdAt: result.session.createdAt, | |
| 87 | + | expiresAt: result.session.expiresAt | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | const user: SafeUser = { | |
| 91 | + | id: result.user._id, | |
| 92 | + | nickname: result.user.nickname, | |
| 93 | + | status: result.user.status as SafeUser['status'], | |
| 94 | + | avatarUrl: result.user.avatarUrl, | |
| 95 | + | lastSeen: result.user.lastSeen | |
| 96 | + | }; | |
| 97 | + | ||
| 98 | + | log.debug('Session validated', { tokenHash, userId: user.id }); | |
| 101 | 99 | return { session, user }; | |
| 102 | 100 | } | |
| 103 | 101 | ||
| 104 | 102 | // Invalidate a specific session by deleting it from the database. | |
| 105 | - | export async function invalidateSession(sessionId: string): Promise<void> { | |
| 106 | - | log.debug('Invalidating session', { sessionId }); | |
| 107 | - | await db.delete(sessions).where(eq(sessions.id, sessionId)); | |
| 108 | - | log.debug('Session invalidated', { sessionId }); | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // Invalidate all sessions for a given user. | |
| 112 | - | export async function invalidateAllSessions(userId: string): Promise<void> { | |
| 113 | - | log.debug('Invalidating all sessions for user', { userId }); | |
| 114 | - | await db.delete(sessions).where(eq(sessions.userId, userId)); | |
| 115 | - | log.debug('All sessions invalidated', { userId }); | |
| 103 | + | export async function invalidateSession(tokenHash: string): Promise<void> { | |
| 104 | + | log.debug('Invalidating session', { tokenHash }); | |
| 105 | + | await sessions.remove(tokenHash); | |
| 106 | + | log.debug('Session invalidated', { tokenHash }); | |
| 116 | 107 | } | |
D src/lib/buddyListCache.ts +0 -55
| @@ -1,55 +0,0 @@ | |||
| 1 | - | import { createLogger } from './utils/logger.server'; | |
| 2 | - | import type { SafeUser } from './types/chat'; | |
| 3 | - | ||
| 4 | - | const log = createLogger('buddy-list-cache'); | |
| 5 | - | ||
| 6 | - | class BuddyListCache { | |
| 7 | - | private cache: SafeUser[] | null = null; | |
| 8 | - | private lastHash: string = ''; | |
| 9 | - | private lastFetch: number = 0; | |
| 10 | - | private isDirty: boolean = true; // Force initial fetch | |
| 11 | - | ||
| 12 | - | // Mark cache as needing refresh | |
| 13 | - | invalidate(): void { | |
| 14 | - | this.isDirty = true; | |
| 15 | - | log.debug('Buddy list cache invalidated'); | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // Check if we need to fetch new data | |
| 19 | - | needsRefresh(): boolean { | |
| 20 | - | return this.isDirty || this.cache === null; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // Update cache with new data | |
| 24 | - | update(users: SafeUser[]): boolean { | |
| 25 | - | const newHash = JSON.stringify(users); | |
| 26 | - | const hasChanged = newHash !== this.lastHash; | |
| 27 | - | ||
| 28 | - | if (hasChanged) { | |
| 29 | - | this.cache = users; | |
| 30 | - | this.lastHash = newHash; | |
| 31 | - | this.lastFetch = Date.now(); | |
| 32 | - | this.isDirty = false; | |
| 33 | - | ||
| 34 | - | log.debug('Buddy list cache updated', { | |
| 35 | - | userCount: users.length, | |
| 36 | - | changed: hasChanged | |
| 37 | - | }); | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return hasChanged; | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // Get cached data | |
| 44 | - | get(): SafeUser[] | null { | |
| 45 | - | return this.cache; | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Get cache age in milliseconds | |
| 49 | - | getAge(): number { | |
| 50 | - | return this.cache ? Date.now() - this.lastFetch : Infinity; | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Export singleton instance | |
| 55 | - | export const buddyListCache = new BuddyListCache(); | |
M src/lib/components/chat-room.svelte +351 -366
| @@ -1,5 +1,8 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import { chatState } from '../states/chat.svelte'; | |
| 3 | + | import { useQuery } from 'convex-svelte'; | |
| 4 | + | import { api } from '../../convex/_generated/api'; | |
| 5 | + | import type { Id } from '../../convex/_generated/dataModel'; | |
| 3 | 6 | import type { Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 4 | 7 | import { onMount } from 'svelte'; | |
| 5 | 8 | import { browser } from '$app/environment'; | |
| @@ -22,117 +25,167 @@ | |||
| 22 | 25 | import { loadWindowState, saveWindowState, debounce } from '$lib/utils/chat-window-state'; | |
| 23 | 26 | import { MAX_MESSAGE_LENGTH } from '$lib/validation/message'; | |
| 24 | 27 | ||
| 25 | - | // Props destructuring must come first | |
| 28 | + | // Props | |
| 26 | 29 | let { showChatRoom = $bindable(), initialTextStyle = DEFAULT_TEXT_STYLE } = $props(); | |
| 27 | 30 | ||
| 28 | - | // Initialize with default values for SSR (800x600 - 16:10 aspect ratio) | |
| 31 | + | // ============ CONVEX REAL-TIME SUBSCRIPTIONS ============ | |
| 32 | + | ||
| 33 | + | // Subscribe to default room (to get room ID) | |
| 34 | + | const defaultRoomQuery = useQuery(api.queries.getDefaultRoomPublic, {}); | |
| 35 | + | ||
| 36 | + | // Subscribe to users (buddy list) - real-time updates | |
| 37 | + | const usersQuery = useQuery(api.queries.getUsersPublic, {}); | |
| 38 | + | ||
| 39 | + | // Room ID from Convex query | |
| 40 | + | const roomId = $derived(defaultRoomQuery.data?.id as Id<'chatRooms'> | undefined); | |
| 41 | + | ||
| 42 | + | // Subscribe to messages - only when room ID is available | |
| 43 | + | const messagesQuery = $derived( | |
| 44 | + | roomId ? useQuery(api.queries.getMessagesPublic, () => ({ roomId: roomId!, limit: 100 })) : null | |
| 45 | + | ); | |
| 46 | + | ||
| 47 | + | // Transform Convex users to SafeUser format | |
| 48 | + | const onlineUsers = $derived.by<SafeUser[]>(() => { | |
| 49 | + | if (!usersQuery.data) return []; | |
| 50 | + | return usersQuery.data | |
| 51 | + | .map((u) => ({ | |
| 52 | + | id: u.id, | |
| 53 | + | nickname: u.nickname, | |
| 54 | + | status: u.status as SafeUser['status'], | |
| 55 | + | avatarUrl: u.avatarUrl ?? null, | |
| 56 | + | lastSeen: u.lastSeen ?? null | |
| 57 | + | })) | |
| 58 | + | .sort((a, b) => { | |
| 59 | + | const statusOrder: Record<string, number> = { online: 0, away: 1, busy: 2, offline: 3 }; | |
| 60 | + | return (statusOrder[a.status] ?? 3) - (statusOrder[b.status] ?? 3); | |
| 61 | + | }); | |
| 62 | + | }); | |
| 63 | + | ||
| 64 | + | // Transform Convex messages to EnrichedMessage format | |
| 65 | + | const messages = $derived.by<EnrichedMessage[]>(() => { | |
| 66 | + | if (!messagesQuery?.data) return []; | |
| 67 | + | return messagesQuery.data.map((msg) => ({ | |
| 68 | + | id: msg.id, | |
| 69 | + | chatRoomId: msg.chatRoomId, | |
| 70 | + | senderId: msg.senderId, | |
| 71 | + | content: msg.content, | |
| 72 | + | type: msg.type as Message['type'], | |
| 73 | + | timestamp: msg.timestamp, | |
| 74 | + | styleData: msg.styleData, | |
| 75 | + | hasFormatting: msg.hasFormatting ?? false, | |
| 76 | + | user: msg.sender | |
| 77 | + | ? { | |
| 78 | + | id: msg.sender.id, | |
| 79 | + | nickname: msg.sender.nickname, | |
| 80 | + | status: msg.sender.status as SafeUser['status'], | |
| 81 | + | avatarUrl: msg.sender.avatarUrl ?? null, | |
| 82 | + | lastSeen: null | |
| 83 | + | } | |
| 84 | + | : { | |
| 85 | + | id: msg.senderId, | |
| 86 | + | nickname: 'Unknown User', | |
| 87 | + | status: 'offline' as const, | |
| 88 | + | avatarUrl: null, | |
| 89 | + | lastSeen: null | |
| 90 | + | } | |
| 91 | + | })); | |
| 92 | + | }); | |
| 93 | + | ||
| 94 | + | // Messages loaded via pagination (older history) | |
| 95 | + | let pagedMessages = $state<Message[]>([]); | |
| 96 | + | ||
| 97 | + | // Merge paginated messages with live subscription data | |
| 98 | + | const allMessages = $derived.by<EnrichedMessage[]>(() => { | |
| 99 | + | // Using object for deduplication (avoiding Map for svelte reactivity compliance) | |
| 100 | + | const merged: Record<string, EnrichedMessage> = {}; | |
| 101 | + | for (const msg of pagedMessages) { | |
| 102 | + | merged[msg.id] = enrichMessage(msg); | |
| 103 | + | } | |
| 104 | + | for (const msg of messages) { | |
| 105 | + | merged[msg.id] = msg; | |
| 106 | + | } | |
| 107 | + | return Object.values(merged).sort((a, b) => a.timestamp - b.timestamp); | |
| 108 | + | }); | |
| 109 | + | ||
| 110 | + | // Loading and error states from Convex | |
| 111 | + | const isInitialLoading = $derived( | |
| 112 | + | defaultRoomQuery.isLoading || usersQuery.isLoading || messagesQuery?.isLoading | |
| 113 | + | ); | |
| 114 | + | const connectionError = $derived.by<string | null>(() => { | |
| 115 | + | if (defaultRoomQuery.error) return 'Erreur de connexion au salon'; | |
| 116 | + | if (usersQuery.error) return 'Erreur de chargement des utilisateurs'; | |
| 117 | + | if (messagesQuery?.error) return 'Erreur de chargement des messages'; | |
| 118 | + | return null; | |
| 119 | + | }); | |
| 120 | + | ||
| 121 | + | // Update chatState with room ID when available | |
| 122 | + | $effect(() => { | |
| 123 | + | if (roomId) { | |
| 124 | + | chatState.setCurrentRoomId(roomId); | |
| 125 | + | chatState.setConnectionStatus('connected'); | |
| 126 | + | } | |
| 127 | + | }); | |
| 128 | + | ||
| 129 | + | // ============ USER STATE ============ | |
| 130 | + | ||
| 131 | + | const currentUser = $derived(chatState.getCurrentUser()); | |
| 132 | + | ||
| 133 | + | // ============ WINDOW STATE ============ | |
| 134 | + | ||
| 29 | 135 | let windowWidth = $state(800); | |
| 30 | 136 | let windowHeight = $state(600); | |
| 31 | 137 | let windowX = $state(0); | |
| 32 | 138 | let windowY = $state(0); | |
| 33 | 139 | let isMobile = $state(false); | |
| 34 | - | let currentMessage = $state(''); | |
| 35 | 140 | let showUserList = $state(false); | |
| 36 | 141 | let isMaximized = $state(false); | |
| 37 | - | let isInitialLoading = $state(true); | |
| 38 | - | let showAuth = $state(false); | |
| 39 | 142 | let isMinimized = $state(false); | |
| 40 | - | let isCentered = $state(true); // true = window stays centered on resize, false = user has manually positioned | |
| 41 | - | let inputScrollLeft = $state(0); // Track input scroll position for gradient overlay sync | |
| 143 | + | let isCentered = $state(true); | |
| 42 | 144 | ||
| 43 | - | // Handle input changes - truncate and sync scroll | |
| 44 | - | function handleInputChange(e: Event) { | |
| 45 | - | const input = e.target as HTMLInputElement; | |
| 46 | - | // Truncate if over limit (handles paste) | |
| 47 | - | if (input.value.length > MAX_MESSAGE_LENGTH) { | |
| 48 | - | input.value = input.value.slice(0, MAX_MESSAGE_LENGTH); | |
| 49 | - | currentMessage = input.value; | |
| 50 | - | } | |
| 51 | - | // Sync scroll position for gradient overlay | |
| 52 | - | inputScrollLeft = input.scrollLeft; | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | function handleInputScroll(e: Event) { | |
| 56 | - | const input = e.target as HTMLInputElement; | |
| 57 | - | inputScrollLeft = input.scrollLeft; | |
| 58 | - | } | |
| 145 | + | // ============ INPUT STATE ============ | |
| 59 | 146 | ||
| 60 | - | // Text formatting state - initialize directly with merged initial style | |
| 147 | + | let currentMessage = $state(''); | |
| 148 | + | let inputScrollLeft = $state(0); | |
| 61 | 149 | let currentTextStyle = $state<TextStyle>({ | |
| 62 | 150 | ...DEFAULT_TEXT_STYLE, | |
| 63 | 151 | ...initialTextStyle, | |
| 64 | 152 | color: initialTextStyle.color || '#000000' | |
| 65 | 153 | }); | |
| 66 | 154 | ||
| 67 | - | // Rate limiting state | |
| 155 | + | // ============ RATE LIMITING STATE ============ | |
| 156 | + | ||
| 68 | 157 | let cooldownEndTime = $state<number | null>(null); | |
| 69 | 158 | let cooldownProgress = $state(0); | |
| 70 | 159 | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | |
| 71 | 160 | let rateLimitWarning = $state<string | null>(null); | |
| 161 | + | let isSendingMessage = $state(false); | |
| 72 | 162 | ||
| 73 | - | // At the top along with other variable declarations, add: | |
| 74 | - | let roomPollInterval: ReturnType<typeof setInterval> | null = null; | |
| 75 | - | ||
| 76 | - | // Debounced save function for window state persistence | |
| 77 | - | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 78 | - | ||
| 79 | - | function updateCooldownProgress() { | |
| 80 | - | if (!cooldownEndTime) return; | |
| 81 | - | ||
| 82 | - | const now = Date.now(); | |
| 83 | - | if (now >= cooldownEndTime) { | |
| 84 | - | cooldownEndTime = null; | |
| 85 | - | cooldownProgress = 0; | |
| 86 | - | rateLimitWarning = null; | |
| 87 | - | if (cooldownInterval) { | |
| 88 | - | clearInterval(cooldownInterval); | |
| 89 | - | cooldownInterval = null; | |
| 90 | - | } | |
| 91 | - | return; | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | cooldownProgress = (cooldownEndTime - now) / 1000; | |
| 95 | - | } | |
| 163 | + | // ============ AUTH STATE ============ | |
| 96 | 164 | ||
| 97 | - | function startCooldownTimer(retryAfter: number) { | |
| 98 | - | cooldownEndTime = Date.now() + retryAfter; | |
| 99 | - | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 100 | - | cooldownInterval = setInterval(updateCooldownProgress, 100); | |
| 101 | - | updateCooldownProgress(); | |
| 102 | - | } | |
| 165 | + | let showAuth = $state(false); | |
| 103 | 166 | ||
| 104 | - | function handleClose() { | |
| 105 | - | showChatRoom = false; | |
| 106 | - | } | |
| 167 | + | // ============ SCROLL STATE ============ | |
| 107 | 168 | ||
| 108 | - | // Reactive state using derived values | |
| 109 | - | let messages = $state<EnrichedMessage[]>([]); | |
| 110 | - | let onlineUsers = $state<SafeUser[]>([]); | |
| 111 | - | let currentUser = $state<SafeUser | null>(null); | |
| 112 | 169 | let isLoadingMore = $state(false); | |
| 113 | 170 | let hasMoreMessages = $state(true); | |
| 114 | 171 | let oldestMessageTimestamp = $state<number | null>(null); | |
| 115 | - | let currentRoomId = $state(''); | |
| 116 | - | ||
| 117 | - | // Compute visible messages based on login status | |
| 118 | - | let visibleMessages = $derived( | |
| 119 | - | (() => { | |
| 120 | - | const isLoggedIn = Boolean(currentUser); | |
| 121 | - | const messageCount = messages.length; | |
| 122 | - | const result: EnrichedMessage[] = isLoggedIn | |
| 123 | - | ? messages | |
| 124 | - | : messages.slice(Math.max(0, messageCount - 50)); | |
| 125 | - | return result; | |
| 126 | - | })() | |
| 127 | - | ); | |
| 172 | + | let lastMessageCount = $state(0); | |
| 173 | + | ||
| 174 | + | // ============ DERIVED STATE ============ | |
| 175 | + | ||
| 176 | + | // Visible messages (limit for non-logged-in users) | |
| 177 | + | const visibleMessages = $derived.by<EnrichedMessage[]>(() => { | |
| 178 | + | const isLoggedIn = Boolean(currentUser); | |
| 179 | + | return isLoggedIn ? allMessages : allMessages.slice(Math.max(0, allMessages.length - 50)); | |
| 180 | + | }); | |
| 128 | 181 | ||
| 129 | - | // Add a derived state to check if we're showing the registration prompt | |
| 130 | - | let showRegistrationPrompt = $derived(!currentUser && messages.length > 50); | |
| 182 | + | // Show registration prompt for non-logged-in users | |
| 183 | + | const showRegistrationPrompt = $derived(!currentUser && allMessages.length > 50); | |
| 131 | 184 | ||
| 132 | - | // Character counter - show when approaching limit (80%+) | |
| 133 | - | const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); // 400 chars | |
| 134 | - | let showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD); | |
| 135 | - | let charCounterClass = $derived( | |
| 185 | + | // Character counter | |
| 186 | + | const CHAR_WARNING_THRESHOLD = Math.floor(MAX_MESSAGE_LENGTH * 0.8); | |
| 187 | + | const showCharCounter = $derived(currentMessage.length >= CHAR_WARNING_THRESHOLD); | |
| 188 | + | const charCounterClass = $derived( | |
| 136 | 189 | currentMessage.length >= MAX_MESSAGE_LENGTH | |
| 137 | 190 | ? 'at-limit' | |
| 138 | 191 | : currentMessage.length >= MAX_MESSAGE_LENGTH * 0.95 | |
| @@ -140,244 +193,133 @@ | |||
| 140 | 193 | : 'warning' | |
| 141 | 194 | ); | |
| 142 | 195 | ||
| 143 | - | // Add SSE error state | |
| 144 | - | let sseError = $state<string | null>(null); | |
| 145 | - | let sseRetryAfter = $state<number | null>(null); | |
| 196 | + | // User counts | |
| 197 | + | const totalUsers = $derived(onlineUsers.length); | |
| 198 | + | const usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |
| 199 | + | const usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |
| 146 | 200 | ||
| 147 | - | // Update the effect to check for SSE errors | |
| 148 | - | $effect(() => { | |
| 149 | - | const user = chatState.getCurrentUser(); | |
| 150 | - | if (user?.id !== currentUser?.id) { | |
| 151 | - | currentUser = user; | |
| 152 | - | hasMoreMessages = true; // Reset when user changes | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // Check for SSE errors | |
| 156 | - | const { error, retryAfter } = chatState.getSSEError(); | |
| 157 | - | sseError = error; | |
| 158 | - | sseRetryAfter = retryAfter; | |
| 201 | + | // ============ EFFECTS ============ | |
| 159 | 202 | ||
| 160 | - | // Update messages if there's any difference | |
| 161 | - | const stateMessages = chatState.getMessages(); | |
| 162 | - | if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) { | |
| 163 | - | messages = stateMessages; | |
| 164 | - | ||
| 165 | - | // Update oldest message timestamp | |
| 166 | - | if (messages.length > 0) { | |
| 167 | - | oldestMessageTimestamp = Math.min(...messages.map((m) => m.timestamp)); | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Scroll to bottom when new messages arrive | |
| 171 | - | const chatArea = document.querySelector('.chat-area'); | |
| 172 | - | if (chatArea) { | |
| 173 | - | setTimeout(() => { | |
| 174 | - | chatArea.scrollTop = chatArea.scrollHeight; | |
| 175 | - | }, 0); | |
| 203 | + | // Auto-scroll to bottom when new messages arrive | |
| 204 | + | $effect(() => { | |
| 205 | + | if (messages.length > lastMessageCount && messages.length > 0) { | |
| 206 | + | lastMessageCount = messages.length; | |
| 207 | + | if (browser) { | |
| 208 | + | const chatArea = document.querySelector('.chat-area'); | |
| 209 | + | if (chatArea) { | |
| 210 | + | setTimeout(() => { | |
| 211 | + | chatArea.scrollTop = chatArea.scrollHeight; | |
| 212 | + | }, 0); | |
| 213 | + | } | |
| 176 | 214 | } | |
| 177 | 215 | } | |
| 178 | - | ||
| 179 | - | // Update online users | |
| 180 | - | const stateOnlineUsers = chatState.getOnlineUsers(); | |
| 181 | - | if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) { | |
| 182 | - | onlineUsers = stateOnlineUsers; | |
| 183 | - | } | |
| 184 | 216 | }); | |
| 185 | 217 | ||
| 186 | - | // Fetch public messages only once when needed | |
| 187 | - | let fetchedPublicMessages = false; | |
| 188 | - | let fetchedPublicRoom = false; | |
| 189 | - | ||
| 218 | + | // Update oldest timestamp for pagination | |
| 190 | 219 | $effect(() => { | |
| 191 | - | if (!currentUser) { | |
| 192 | - | // Fetch both public room data and messages in parallel but handle them in sequence | |
| 193 | - | if (!fetchedPublicRoom && !fetchedPublicMessages) { | |
| 194 | - | fetchedPublicRoom = true; | |
| 195 | - | fetchedPublicMessages = true; | |
| 196 | - | ||
| 197 | - | // Get the default chat room id | |
| 198 | - | const defaultRoomId = chatState.getDefaultChatRoomId(); | |
| 199 | - | ||
| 200 | - | // Fetch both in parallel | |
| 201 | - | Promise.all([ | |
| 202 | - | fetch(`/api/rooms/${defaultRoomId}?public=true`).then((r) => r.json()), | |
| 203 | - | fetch('/api/chat/messages?public=true').then((r) => r.json()) | |
| 204 | - | ]) | |
| 205 | - | .then(([roomData, messagesData]) => { | |
| 206 | - | if (roomData.success) { | |
| 207 | - | // Update both the chat state and local state with the buddy list | |
| 208 | - | chatState.updateOnlineUsers(roomData.buddyList); | |
| 209 | - | onlineUsers = roomData.buddyList; | |
| 210 | - | console.debug('Updated public buddy list:', roomData.buddyList); | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | // Then update messages after buddy list is cached | |
| 214 | - | if (messagesData.success && messagesData.messages) { | |
| 215 | - | chatState.updateMessages(messagesData.messages); | |
| 216 | - | messages = chatState.enrichMessages(messagesData.messages); | |
| 217 | - | console.debug('Updated public messages:', messages); | |
| 218 | - | } | |
| 219 | - | }) | |
| 220 | - | .catch((error) => { | |
| 221 | - | console.error('Error fetching public data:', error); | |
| 222 | - | onlineUsers = []; | |
| 223 | - | messages = []; | |
| 224 | - | }); | |
| 225 | - | } | |
| 220 | + | if (allMessages.length > 0) { | |
| 221 | + | oldestMessageTimestamp = Math.min(...allMessages.map((m) => m.timestamp)); | |
| 226 | 222 | } | |
| 227 | 223 | }); | |
| 228 | 224 | ||
| 229 | - | onMount(() => { | |
| 230 | - | if (!browser) return; | |
| 225 | + | // ============ HELPERS ============ | |
| 226 | + | ||
| 227 | + | function resolveUser(senderId: string): SafeUser { | |
| 228 | + | const knownUser = onlineUsers.find((user) => user.id === senderId); | |
| 229 | + | if (knownUser) return knownUser; | |
| 230 | + | return { | |
| 231 | + | id: senderId, | |
| 232 | + | nickname: 'Unknown User', | |
| 233 | + | status: 'offline', | |
| 234 | + | avatarUrl: null, | |
| 235 | + | lastSeen: null | |
| 236 | + | }; | |
| 237 | + | } | |
| 231 | 238 | ||
| 232 | - | // Detect mobile first | |
| 233 | - | isMobile = window.innerWidth <= 768; | |
| 239 | + | function enrichMessage(message: Message): EnrichedMessage { | |
| 240 | + | return { | |
| 241 | + | ...message, | |
| 242 | + | user: resolveUser(message.senderId) | |
| 243 | + | }; | |
| 244 | + | } | |
| 234 | 245 | ||
| 235 | - | // Load saved state from localStorage (only for desktop) | |
| 236 | - | if (!isMobile) { | |
| 237 | - | const savedState = loadWindowState(); | |
| 238 | - | // Only apply saved position/size if they're within current viewport bounds | |
| 239 | - | const maxWidth = window.innerWidth - 40; | |
| 240 | - | const maxHeight = window.innerHeight - 40; | |
| 246 | + | function mergePagedMessages(incoming: Message[]) { | |
| 247 | + | if (incoming.length === 0) return; | |
| 248 | + | const existingIds = new Set(pagedMessages.map((message) => message.id)); | |
| 249 | + | const newMessages = incoming.filter((message) => !existingIds.has(message.id)); | |
| 250 | + | if (newMessages.length === 0) return; | |
| 251 | + | pagedMessages = [...pagedMessages, ...newMessages]; | |
| 252 | + | } | |
| 241 | 253 | ||
| 242 | - | windowWidth = Math.min(savedState.width, maxWidth); | |
| 243 | - | windowHeight = Math.min(savedState.height, maxHeight); | |
| 254 | + | const debouncedSaveWindowState = debounce(saveWindowState, 300); | |
| 244 | 255 | ||
| 245 | - | // Handle centering: if x or y is -1, center the window | |
| 246 | - | if (savedState.x === -1) { | |
| 247 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 248 | - | } else { | |
| 249 | - | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 250 | - | } | |
| 251 | - | if (savedState.y === -1) { | |
| 252 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 253 | - | } else { | |
| 254 | - | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 256 | + | function updateCooldownProgress() { | |
| 257 | + | if (!cooldownEndTime) return; | |
| 258 | + | const now = Date.now(); | |
| 259 | + | if (now >= cooldownEndTime) { | |
| 260 | + | cooldownEndTime = null; | |
| 261 | + | cooldownProgress = 0; | |
| 262 | + | rateLimitWarning = null; | |
| 263 | + | if (cooldownInterval) { | |
| 264 | + | clearInterval(cooldownInterval); | |
| 265 | + | cooldownInterval = null; | |
| 255 | 266 | } | |
| 256 | - | ||
| 257 | - | isMaximized = savedState.isMaximized; | |
| 258 | - | isMinimized = savedState.isMinimized; | |
| 259 | - | showUserList = savedState.showUserList; | |
| 260 | - | isCentered = savedState.isCentered; | |
| 267 | + | return; | |
| 261 | 268 | } | |
| 269 | + | cooldownProgress = (cooldownEndTime - now) / 1000; | |
| 270 | + | } | |
| 262 | 271 | ||
| 263 | - | const handleResize = () => { | |
| 264 | - | const wasMobile = isMobile; | |
| 265 | - | isMobile = window.innerWidth <= 768; | |
| 266 | - | if (isMobile) { | |
| 267 | - | windowWidth = window.innerWidth; | |
| 268 | - | windowHeight = window.innerHeight; | |
| 269 | - | windowX = 0; | |
| 270 | - | windowY = 0; | |
| 271 | - | } else if (wasMobile && !isMobile) { | |
| 272 | - | // Switching from mobile to desktop - reload saved state | |
| 273 | - | const savedState = loadWindowState(); | |
| 274 | - | const maxWidth = window.innerWidth - 40; | |
| 275 | - | const maxHeight = window.innerHeight - 40; | |
| 276 | - | windowWidth = Math.min(savedState.width, maxWidth); | |
| 277 | - | windowHeight = Math.min(savedState.height, maxHeight); | |
| 278 | - | ||
| 279 | - | // Handle centering | |
| 280 | - | if (savedState.x === -1) { | |
| 281 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 282 | - | } else { | |
| 283 | - | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 284 | - | } | |
| 285 | - | if (savedState.y === -1) { | |
| 286 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 287 | - | } else { | |
| 288 | - | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 289 | - | } | |
| 290 | - | } else if (!isMobile) { | |
| 291 | - | // Desktop viewport changed (zoom in/out) - re-center if isCentered, otherwise keep in bounds | |
| 292 | - | const maxWidth = window.innerWidth - 40; | |
| 293 | - | const maxHeight = window.innerHeight - 40; | |
| 294 | - | windowWidth = Math.min(windowWidth, maxWidth); | |
| 295 | - | windowHeight = Math.min(windowHeight, maxHeight); | |
| 296 | - | ||
| 297 | - | if (isCentered) { | |
| 298 | - | // Keep window centered | |
| 299 | - | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 300 | - | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 301 | - | } else { | |
| 302 | - | // Just keep window within bounds | |
| 303 | - | windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth)); | |
| 304 | - | windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight)); | |
| 305 | - | } | |
| 306 | - | } | |
| 307 | - | }; | |
| 308 | - | ||
| 309 | - | // Only run handleResize if on mobile (desktop already loaded from localStorage) | |
| 310 | - | if (isMobile) { | |
| 311 | - | handleResize(); | |
| 312 | - | } | |
| 272 | + | function startCooldownTimer(retryAfter: number) { | |
| 273 | + | cooldownEndTime = Date.now() + retryAfter; | |
| 274 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 275 | + | cooldownInterval = setInterval(updateCooldownProgress, 100); | |
| 276 | + | updateCooldownProgress(); | |
| 277 | + | } | |
| 313 | 278 | ||
| 314 | - | // Add resize listener | |
| 315 | - | window.addEventListener('resize', handleResize); | |
| 279 | + | // ============ HANDLERS ============ | |
| 316 | 280 | ||
| 317 | - | // ---- New: Polling for public buddy list updates ---- | |
| 318 | - | if (!currentUser) { | |
| 319 | - | const fetchPublicRoom = async () => { | |
| 320 | - | const defaultRoomId = chatState.getDefaultChatRoomId(); | |
| 321 | - | try { | |
| 322 | - | const response = await fetch(`/api/rooms/${defaultRoomId}?public=true`); | |
| 323 | - | if (response.ok) { | |
| 324 | - | const roomData = await response.json(); | |
| 325 | - | if (roomData.success && roomData.buddyList) { | |
| 326 | - | // Update both chat state and local onlineUsers list | |
| 327 | - | chatState.updateOnlineUsers(roomData.buddyList); | |
| 328 | - | onlineUsers = roomData.buddyList; | |
| 329 | - | console.debug('Public polling updated buddy list:', roomData.buddyList); | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | } catch (error) { | |
| 333 | - | console.error('Error fetching public buddy list:', error); | |
| 334 | - | } | |
| 335 | - | }; | |
| 281 | + | function handleClose() { | |
| 282 | + | showChatRoom = false; | |
| 283 | + | } | |
| 336 | 284 | ||
| 337 | - | // Initial fetch then poll every 30 seconds | |
| 338 | - | fetchPublicRoom(); | |
| 339 | - | roomPollInterval = setInterval(fetchPublicRoom, 30000); | |
| 285 | + | function handleInputChange(e: Event) { | |
| 286 | + | const input = e.target as HTMLInputElement; | |
| 287 | + | if (input.value.length > MAX_MESSAGE_LENGTH) { | |
| 288 | + | input.value = input.value.slice(0, MAX_MESSAGE_LENGTH); | |
| 289 | + | currentMessage = input.value; | |
| 340 | 290 | } | |
| 341 | - | // ------------------------------------------------------- | |
| 342 | - | ||
| 343 | - | return () => { | |
| 344 | - | window.removeEventListener('resize', handleResize); | |
| 345 | - | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 346 | - | if (roomPollInterval) clearInterval(roomPollInterval); | |
| 347 | - | }; | |
| 348 | - | }); | |
| 349 | - | ||
| 350 | - | // Save window state to localStorage when it changes (desktop only) | |
| 351 | - | $effect(() => { | |
| 352 | - | if (!browser || isMobile) return; | |
| 353 | - | ||
| 354 | - | // Save current state (debounced) | |
| 355 | - | debouncedSaveWindowState({ | |
| 356 | - | width: windowWidth, | |
| 357 | - | height: windowHeight, | |
| 358 | - | x: windowX, | |
| 359 | - | y: windowY, | |
| 360 | - | isMaximized, | |
| 361 | - | isMinimized, | |
| 362 | - | showUserList, | |
| 363 | - | isCentered | |
| 364 | - | }); | |
| 365 | - | }); | |
| 291 | + | inputScrollLeft = input.scrollLeft; | |
| 292 | + | } | |
| 366 | 293 | ||
| 367 | - | // New reactive state to track when a message is being sent | |
| 368 | - | let isSendingMessage = $state(false); | |
| 294 | + | function handleInputScroll(e: Event) { | |
| 295 | + | inputScrollLeft = (e.target as HTMLInputElement).scrollLeft; | |
| 296 | + | } | |
| 369 | 297 | ||
| 370 | 298 | async function handleSubmit() { | |
| 371 | 299 | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | |
| 372 | 300 | ||
| 373 | 301 | isSendingMessage = true; | |
| 374 | 302 | try { | |
| 375 | - | const response = await chatState.sendMessage(currentMessage, 'chat', currentTextStyle); | |
| 376 | - | if (!response.success && response.isRateLimited && response.retryAfter) { | |
| 377 | - | startCooldownTimer(response.retryAfter); | |
| 303 | + | const response = await fetch('/api/chat/messages', { | |
| 304 | + | method: 'POST', | |
| 305 | + | headers: { 'Content-Type': 'application/json' }, | |
| 306 | + | credentials: 'include', | |
| 307 | + | body: JSON.stringify({ | |
| 308 | + | content: currentMessage, | |
| 309 | + | type: 'chat', | |
| 310 | + | userId: currentUser.id, | |
| 311 | + | chatRoomId: roomId, | |
| 312 | + | styleData: currentTextStyle ? JSON.stringify(currentTextStyle) : undefined | |
| 313 | + | }) | |
| 314 | + | }); | |
| 315 | + | ||
| 316 | + | const data = await response.json(); | |
| 317 | + | ||
| 318 | + | if (!data.success && data.isRateLimited && data.retryAfter) { | |
| 319 | + | startCooldownTimer(data.retryAfter); | |
| 378 | 320 | rateLimitWarning = "Whoa there! You're sending messages too quickly. Take a breather..."; | |
| 379 | - | } else if (!response.success) { | |
| 380 | - | rateLimitWarning = response.error || 'Failed to send message. Please try again.'; | |
| 321 | + | } else if (!data.success) { | |
| 322 | + | rateLimitWarning = data.error || 'Failed to send message. Please try again.'; | |
| 381 | 323 | setTimeout(() => (rateLimitWarning = null), 3000); | |
| 382 | 324 | } else { | |
| 383 | 325 | currentMessage = ''; | |
| @@ -395,7 +337,6 @@ | |||
| 395 | 337 | function handleDragMove(event: CustomEvent<{ x: number; y: number }>) { | |
| 396 | 338 | windowX = event.detail.x; | |
| 397 | 339 | windowY = event.detail.y; | |
| 398 | - | // User manually moved the window, no longer keep it centered on resize | |
| 399 | 340 | isCentered = false; | |
| 400 | 341 | } | |
| 401 | 342 | ||
| @@ -406,16 +347,12 @@ | |||
| 406 | 347 | function handleMaximize(event: MouseEvent) { | |
| 407 | 348 | const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode; | |
| 408 | 349 | if (node) { | |
| 409 | - | // If minimized, restore first | |
| 410 | 350 | if (isMinimized) { | |
| 411 | 351 | const minimizableNode = node as unknown as MinimizableNode; | |
| 412 | 352 | if (minimizableNode?.toggleMinimize) { | |
| 413 | 353 | minimizableNode.toggleMinimize(); | |
| 414 | 354 | } | |
| 415 | - | // Wait for minimize animation to complete | |
| 416 | - | setTimeout(() => { | |
| 417 | - | node.toggleMaximize(); | |
| 418 | - | }, 300); | |
| 355 | + | setTimeout(() => node.toggleMaximize(), 300); | |
| 419 | 356 | } else { | |
| 420 | 357 | node.toggleMaximize(); | |
| 421 | 358 | } | |
| @@ -446,28 +383,21 @@ | |||
| 446 | 383 | const chatArea = event.target as HTMLElement; | |
| 447 | 384 | const { scrollTop } = chatArea; | |
| 448 | 385 | ||
| 449 | - | // Check if we've scrolled near the top (within 100px) and not already loading | |
| 450 | - | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | |
| 386 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages && roomId) { | |
| 451 | 387 | isLoadingMore = true; | |
| 452 | - | ||
| 453 | - | // Store the scroll height and position before loading | |
| 454 | 388 | const scrollHeight = chatArea.scrollHeight; | |
| 455 | 389 | const currentScrollTop = chatArea.scrollTop; | |
| 456 | 390 | ||
| 457 | - | console.debug('Loading more messages...', { | |
| 458 | - | oldestMessageTimestamp, | |
| 459 | - | scrollHeight, | |
| 460 | - | currentScrollTop | |
| 461 | - | }); | |
| 462 | - | ||
| 463 | 391 | try { | |
| 464 | - | const response = await fetch( | |
| 465 | - | `/api/chat/messages?${new URLSearchParams({ | |
| 466 | - | before: oldestMessageTimestamp?.toString() || '', | |
| 467 | - | roomId: currentRoomId, | |
| 468 | - | ...(currentUser ? {} : { public: 'true' }) | |
| 469 | - | })}` | |
| 470 | - | ); | |
| 392 | + | // Build query string manually to avoid URLSearchParams (svelte reactivity compliance) | |
| 393 | + | const queryParts = [ | |
| 394 | + | `before=${encodeURIComponent(oldestMessageTimestamp?.toString() || '')}`, | |
| 395 | + | `roomId=${encodeURIComponent(roomId)}` | |
| 396 | + | ]; | |
| 397 | + | if (!currentUser) queryParts.push('public=true'); | |
| 398 | + | const queryString = queryParts.join('&'); | |
| 399 | + | ||
| 400 | + | const response = await fetch(`/api/chat/messages?${queryString}`); | |
| 471 | 401 | ||
| 472 | 402 | if (response.ok) { | |
| 473 | 403 | const data = await response.json(); | |
| @@ -475,30 +405,19 @@ | |||
| 475 | 405 | if (data.messages.length === 0) { | |
| 476 | 406 | hasMoreMessages = false; | |
| 477 | 407 | } else { | |
| 478 | - | // Update messages through chat state | |
| 479 | - | chatState.prependMessages(data.messages, data.hasMore); | |
| 408 | + | const fetchedMessages = data.messages as Message[]; | |
| 409 | + | mergePagedMessages(fetchedMessages); | |
| 480 | 410 | ||
| 481 | - | // Use requestAnimationFrame to ensure DOM has updated | |
| 482 | 411 | requestAnimationFrame(() => { | |
| 483 | - | // Calculate how much the content height has increased | |
| 484 | 412 | const newScrollHeight = chatArea.scrollHeight; | |
| 485 | 413 | const heightDifference = newScrollHeight - scrollHeight; | |
| 486 | - | ||
| 487 | - | // Adjust scroll position to maintain relative position | |
| 488 | 414 | chatArea.scrollTop = currentScrollTop + heightDifference; | |
| 489 | - | ||
| 490 | - | console.debug('Scroll position adjusted:', { | |
| 491 | - | heightDifference, | |
| 492 | - | newScrollTop: chatArea.scrollTop, | |
| 493 | - | newScrollHeight | |
| 494 | - | }); | |
| 495 | 415 | }); | |
| 496 | 416 | ||
| 497 | - | // Update oldest timestamp | |
| 498 | - | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | |
| 499 | - | oldestMessageTimestamp = newOldest; | |
| 500 | - | ||
| 501 | - | // Update hasMoreMessages from response | |
| 417 | + | const newOldest = Math.min(...fetchedMessages.map((m) => m.timestamp)); | |
| 418 | + | oldestMessageTimestamp = oldestMessageTimestamp | |
| 419 | + | ? Math.min(oldestMessageTimestamp, newOldest) | |
| 420 | + | : newOldest; | |
| 502 | 421 | hasMoreMessages = data.hasMore; | |
| 503 | 422 | } | |
| 504 | 423 | } | |
| @@ -512,21 +431,6 @@ | |||
| 512 | 431 | } | |
| 513 | 432 | } | |
| 514 | 433 | ||
| 515 | - | // Add effect to track initial loading state | |
| 516 | - | $effect(() => { | |
| 517 | - | const stateMessages = chatState.getMessages(); | |
| 518 | - | if (stateMessages.length > 0 && isInitialLoading) { | |
| 519 | - | isInitialLoading = false; | |
| 520 | - | } | |
| 521 | - | }); | |
| 522 | - | ||
| 523 | - | // Add derived state for total users count | |
| 524 | - | let totalUsers = $derived(onlineUsers.length); | |
| 525 | - | ||
| 526 | - | // Split users into online and offline groups | |
| 527 | - | let usersOnline = $derived(onlineUsers.filter((u) => u.status !== 'offline')); | |
| 528 | - | let usersOffline = $derived(onlineUsers.filter((u) => u.status === 'offline')); | |
| 529 | - | ||
| 530 | 434 | function openSignup() { | |
| 531 | 435 | showAuth = true; | |
| 532 | 436 | } | |
| @@ -534,6 +438,105 @@ | |||
| 534 | 438 | function handleLoginSuccess() { | |
| 535 | 439 | showAuth = false; | |
| 536 | 440 | } | |
| 441 | + | ||
| 442 | + | // ============ LIFECYCLE ============ | |
| 443 | + | ||
| 444 | + | onMount(() => { | |
| 445 | + | if (!browser) return; | |
| 446 | + | ||
| 447 | + | isMobile = window.innerWidth <= 768; | |
| 448 | + | ||
| 449 | + | if (!isMobile) { | |
| 450 | + | const savedState = loadWindowState(); | |
| 451 | + | const maxWidth = window.innerWidth - 40; | |
| 452 | + | const maxHeight = window.innerHeight - 40; | |
| 453 | + | ||
| 454 | + | windowWidth = Math.min(savedState.width, maxWidth); | |
| 455 | + | windowHeight = Math.min(savedState.height, maxHeight); | |
| 456 | + | ||
| 457 | + | if (savedState.x === -1) { | |
| 458 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 459 | + | } else { | |
| 460 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 461 | + | } | |
| 462 | + | if (savedState.y === -1) { | |
| 463 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 464 | + | } else { | |
| 465 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | isMaximized = savedState.isMaximized; | |
| 469 | + | isMinimized = savedState.isMinimized; | |
| 470 | + | showUserList = savedState.showUserList; | |
| 471 | + | isCentered = savedState.isCentered; | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | const handleResize = () => { | |
| 475 | + | const wasMobile = isMobile; | |
| 476 | + | isMobile = window.innerWidth <= 768; | |
| 477 | + | ||
| 478 | + | if (isMobile) { | |
| 479 | + | windowWidth = window.innerWidth; | |
| 480 | + | windowHeight = window.innerHeight; | |
| 481 | + | windowX = 0; | |
| 482 | + | windowY = 0; | |
| 483 | + | } else if (wasMobile && !isMobile) { | |
| 484 | + | const savedState = loadWindowState(); | |
| 485 | + | const maxWidth = window.innerWidth - 40; | |
| 486 | + | const maxHeight = window.innerHeight - 40; | |
| 487 | + | windowWidth = Math.min(savedState.width, maxWidth); | |
| 488 | + | windowHeight = Math.min(savedState.height, maxHeight); | |
| 489 | + | ||
| 490 | + | if (savedState.x === -1) { | |
| 491 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 492 | + | } else { | |
| 493 | + | windowX = Math.max(0, Math.min(savedState.x, window.innerWidth - windowWidth)); | |
| 494 | + | } | |
| 495 | + | if (savedState.y === -1) { | |
| 496 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 497 | + | } else { | |
| 498 | + | windowY = Math.max(0, Math.min(savedState.y, window.innerHeight - windowHeight)); | |
| 499 | + | } | |
| 500 | + | } else if (!isMobile) { | |
| 501 | + | const maxWidth = window.innerWidth - 40; | |
| 502 | + | const maxHeight = window.innerHeight - 40; | |
| 503 | + | windowWidth = Math.min(windowWidth, maxWidth); | |
| 504 | + | windowHeight = Math.min(windowHeight, maxHeight); | |
| 505 | + | ||
| 506 | + | if (isCentered) { | |
| 507 | + | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 508 | + | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 509 | + | } else { | |
| 510 | + | windowX = Math.max(0, Math.min(windowX, window.innerWidth - windowWidth)); | |
| 511 | + | windowY = Math.max(0, Math.min(windowY, window.innerHeight - windowHeight)); | |
| 512 | + | } | |
| 513 | + | } | |
| 514 | + | }; | |
| 515 | + | ||
| 516 | + | if (isMobile) handleResize(); | |
| 517 | + | ||
| 518 | + | window.addEventListener('resize', handleResize); | |
| 519 | + | ||
| 520 | + | return () => { | |
| 521 | + | window.removeEventListener('resize', handleResize); | |
| 522 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 523 | + | }; | |
| 524 | + | }); | |
| 525 | + | ||
| 526 | + | // Save window state when it changes | |
| 527 | + | $effect(() => { | |
| 528 | + | if (!browser || isMobile) return; | |
| 529 | + | debouncedSaveWindowState({ | |
| 530 | + | width: windowWidth, | |
| 531 | + | height: windowHeight, | |
| 532 | + | x: windowX, | |
| 533 | + | y: windowY, | |
| 534 | + | isMaximized, | |
| 535 | + | isMinimized, | |
| 536 | + | showUserList, | |
| 537 | + | isCentered | |
| 538 | + | }); | |
| 539 | + | }); | |
| 537 | 540 | </script> | |
| 538 | 541 | ||
| 539 | 542 | {#if showChatRoom} | |
| @@ -567,8 +570,8 @@ | |||
| 567 | 570 | Pdr Aim {#if currentUser} | |
| 568 | 571 | - {currentUser.nickname}{:else} | |
| 569 | 572 | - {totalUsers} membre{totalUsers > 1 ? 's' : ''}{/if} | |
| 570 | - | {#if sseError && !isMinimized} | |
| 571 | - | <span class="connection-error">⚠️ Erreur de connexion</span> | |
| 573 | + | {#if connectionError && !isMinimized} | |
| 574 | + | <span class="connection-error">⚠️ {connectionError}</span> | |
| 572 | 575 | {/if} | |
| 573 | 576 | </div> | |
| 574 | 577 | <div class="title-bar-controls"> | |
| @@ -612,13 +615,9 @@ | |||
| 612 | 615 | class="window-body" | |
| 613 | 616 | style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;" | |
| 614 | 617 | > | |
| 615 | - | {#if sseError} | |
| 618 | + | {#if connectionError} | |
| 616 | 619 | <div class="error-banner"> | |
| 617 | - | {sseError} | |
| 618 | - | {#if sseRetryAfter} | |
| 619 | - | <br /> | |
| 620 | - | <small>Nouvelle tentative dans {Math.ceil(sseRetryAfter)}s...</small> | |
| 621 | - | {/if} | |
| 620 | + | {connectionError} | |
| 622 | 621 | </div> | |
| 623 | 622 | {/if} | |
| 624 | 623 | ||
| @@ -896,7 +895,6 @@ | |||
| 896 | 895 | line-height: 1.4; | |
| 897 | 896 | } | |
| 898 | 897 | ||
| 899 | - | /* Fix gradient text alignment in messages */ | |
| 900 | 898 | .message :global(.gradient-text-static) { | |
| 901 | 899 | display: inline; | |
| 902 | 900 | vertical-align: baseline; | |
| @@ -923,7 +921,6 @@ | |||
| 923 | 921 | vertical-align: baseline; | |
| 924 | 922 | } | |
| 925 | 923 | ||
| 926 | - | /* Ensure formatted message content aligns properly with nickname */ | |
| 927 | 924 | .message-content :global(.formatted-message), | |
| 928 | 925 | .message-content :global(.gradient-text-static) { | |
| 929 | 926 | display: inline; | |
| @@ -975,15 +972,6 @@ | |||
| 975 | 972 | cursor: help; | |
| 976 | 973 | } | |
| 977 | 974 | ||
| 978 | - | /* .user .status-message { | |
| 979 | - | font-size: 0.875rem; | |
| 980 | - | color: #666; | |
| 981 | - | font-style: italic; | |
| 982 | - | overflow: hidden; | |
| 983 | - | text-overflow: ellipsis; | |
| 984 | - | white-space: nowrap; | |
| 985 | - | } */ | |
| 986 | - | ||
| 987 | 975 | .sunken-panel { | |
| 988 | 976 | background: white; | |
| 989 | 977 | border: 0.125rem inset #dfdfdf; | |
| @@ -994,9 +982,7 @@ | |||
| 994 | 982 | margin-right: 0.25rem; | |
| 995 | 983 | } | |
| 996 | 984 | ||
| 997 | - | /* Styled input - font is controlled by retro-font-* classes */ | |
| 998 | 985 | .styled-input { | |
| 999 | - | /* Font family is set by retro-font-* classes, don't override here */ | |
| 1000 | 986 | font-style: inherit; | |
| 1001 | 987 | } | |
| 1002 | 988 | ||
| @@ -1132,7 +1118,6 @@ | |||
| 1132 | 1118 | position: relative; | |
| 1133 | 1119 | } | |
| 1134 | 1120 | ||
| 1135 | - | /* Character counter styles */ | |
| 1136 | 1121 | .char-counter { | |
| 1137 | 1122 | font-size: 0.75rem; | |
| 1138 | 1123 | padding: 0 0.25rem; | |
M src/lib/components/user-preferences-modal.svelte +11 -3
| @@ -45,11 +45,19 @@ | |||
| 45 | 45 | ||
| 46 | 46 | // Save preferences | |
| 47 | 47 | async function savePreferences() { | |
| 48 | - | if (!onSave) return; | |
| 49 | - | ||
| 50 | 48 | isSaving = true; | |
| 51 | 49 | try { | |
| 52 | - | await onSave(preferences); | |
| 50 | + | const response = await fetch('/api/user/text-preferences', { | |
| 51 | + | method: 'POST', | |
| 52 | + | headers: { 'Content-Type': 'application/json' }, | |
| 53 | + | body: JSON.stringify(preferences) | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | if (!response.ok) { | |
| 57 | + | throw new Error('Failed to save preferences'); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | await onSave?.(preferences); | |
| 53 | 61 | showModal = false; | |
| 54 | 62 | } catch (error) { | |
| 55 | 63 | console.error('Failed to save preferences:', error); | |
A src/lib/db/convex.server.ts +196 -0
| @@ -0,0 +1,196 @@ | |||
| 1 | + | import { env } from '$env/dynamic/private'; | |
| 2 | + | import { PUBLIC_CONVEX_URL } from '$env/static/public'; | |
| 3 | + | ||
| 4 | + | // Convert .cloud URL to .site URL for HTTP actions | |
| 5 | + | function getHttpUrl(): string { | |
| 6 | + | return PUBLIC_CONVEX_URL.replace('.convex.cloud', '.convex.site'); | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | // Generic fetch helper for Convex HTTP endpoints (server-side only) | |
| 10 | + | async function convexFetch<T>(path: string, body: Record<string, unknown> = {}): Promise<T> { | |
| 11 | + | const url = `${getHttpUrl()}${path}`; | |
| 12 | + | ||
| 13 | + | const response = await fetch(url, { | |
| 14 | + | method: 'POST', | |
| 15 | + | headers: { | |
| 16 | + | 'Content-Type': 'application/json', | |
| 17 | + | 'X-Convex-Secret': env.CONVEX_API_SECRET || '' | |
| 18 | + | }, | |
| 19 | + | body: JSON.stringify(body) | |
| 20 | + | }); | |
| 21 | + | ||
| 22 | + | if (!response.ok) { | |
| 23 | + | const error = await response.text(); | |
| 24 | + | throw new Error(`Convex HTTP error: ${response.status} - ${error}`); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | return response.json(); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ============ USERS ============ | |
| 31 | + | ||
| 32 | + | export const users = { | |
| 33 | + | getByNickname: (nickname: string) => | |
| 34 | + | convexFetch<ConvexUser | null>('/api/users/getByNickname', { nickname }), | |
| 35 | + | ||
| 36 | + | getById: (id: string) => convexFetch<ConvexUser | null>('/api/users/getById', { id }), | |
| 37 | + | ||
| 38 | + | create: (nickname: string, password: string) => | |
| 39 | + | convexFetch<{ userId: string }>('/api/users/create', { nickname, password }), | |
| 40 | + | ||
| 41 | + | updateStatus: (userId: string, status: string) => | |
| 42 | + | convexFetch<ConvexUser>('/api/users/updateStatus', { userId, status }), | |
| 43 | + | ||
| 44 | + | list: () => convexFetch<ConvexUser[]>('/api/users/list', {}), | |
| 45 | + | ||
| 46 | + | setAllOffline: () => convexFetch<{ updated: number }>('/api/users/setAllOffline', {}) | |
| 47 | + | }; | |
| 48 | + | ||
| 49 | + | // ============ SESSIONS ============ | |
| 50 | + | ||
| 51 | + | export const sessions = { | |
| 52 | + | create: (tokenHash: string, userId: string, expiresAt: number) => | |
| 53 | + | convexFetch<{ id: string; tokenHash: string }>('/api/sessions/create', { | |
| 54 | + | tokenHash, | |
| 55 | + | userId, | |
| 56 | + | expiresAt | |
| 57 | + | }), | |
| 58 | + | ||
| 59 | + | validate: (tokenHash: string) => | |
| 60 | + | convexFetch<{ session: ConvexSession | null; user: ConvexUser | null }>( | |
| 61 | + | '/api/sessions/validate', | |
| 62 | + | { tokenHash } | |
| 63 | + | ), | |
| 64 | + | ||
| 65 | + | remove: (tokenHash: string) => | |
| 66 | + | convexFetch<{ success: boolean }>('/api/sessions/remove', { tokenHash }) | |
| 67 | + | }; | |
| 68 | + | ||
| 69 | + | // ============ CHAT ROOMS ============ | |
| 70 | + | ||
| 71 | + | export const chatRooms = { | |
| 72 | + | getOrCreateDefault: () => convexFetch<ConvexChatRoom>('/api/chatRooms/getOrCreateDefault', {}), | |
| 73 | + | ||
| 74 | + | getById: (id: string) => convexFetch<ConvexChatRoom | null>('/api/chatRooms/getById', { id }) | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | // ============ MESSAGES ============ | |
| 78 | + | ||
| 79 | + | export const messages = { | |
| 80 | + | getByRoom: (roomId: string, limit?: number, beforeTimestamp?: number) => | |
| 81 | + | convexFetch<{ messages: ConvexMessage[]; hasMore: boolean }>('/api/messages/getByRoom', { | |
| 82 | + | roomId, | |
| 83 | + | limit, | |
| 84 | + | beforeTimestamp | |
| 85 | + | }), | |
| 86 | + | ||
| 87 | + | send: ( | |
| 88 | + | chatRoomId: string, | |
| 89 | + | senderId: string, | |
| 90 | + | content: string, | |
| 91 | + | type?: string, | |
| 92 | + | styleData?: string, | |
| 93 | + | hasFormatting?: boolean | |
| 94 | + | ) => | |
| 95 | + | convexFetch<ConvexMessage>('/api/messages/send', { | |
| 96 | + | chatRoomId, | |
| 97 | + | senderId, | |
| 98 | + | content, | |
| 99 | + | type, | |
| 100 | + | styleData, | |
| 101 | + | hasFormatting | |
| 102 | + | }) | |
| 103 | + | }; | |
| 104 | + | ||
| 105 | + | // ============ TYPES ============ | |
| 106 | + | ||
| 107 | + | export interface ConvexUser { | |
| 108 | + | _id: string; | |
| 109 | + | _creationTime: number; | |
| 110 | + | password: string; | |
| 111 | + | nickname: string; | |
| 112 | + | status: string; | |
| 113 | + | avatarUrl?: string; | |
| 114 | + | createdAt: number; | |
| 115 | + | lastSeen?: number; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | export interface ConvexSession { | |
| 119 | + | _id: string; | |
| 120 | + | _creationTime: number; | |
| 121 | + | tokenHash: string; | |
| 122 | + | userId: string; | |
| 123 | + | expiresAt: number; | |
| 124 | + | createdAt: number; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | export interface ConvexMessage { | |
| 128 | + | _id: string; | |
| 129 | + | _creationTime: number; | |
| 130 | + | chatRoomId: string; | |
| 131 | + | senderId: string; | |
| 132 | + | content: string; | |
| 133 | + | type: string; | |
| 134 | + | timestamp: number; | |
| 135 | + | styleData?: string; | |
| 136 | + | hasFormatting?: boolean; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | export interface ConvexChatRoom { | |
| 140 | + | _id: string; | |
| 141 | + | _creationTime: number; | |
| 142 | + | name?: string; | |
| 143 | + | type: string; | |
| 144 | + | createdAt: number; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | export interface ConvexTextPreferences { | |
| 148 | + | _id: string; | |
| 149 | + | _creationTime: number; | |
| 150 | + | userId: string; | |
| 151 | + | defaultStyle: { | |
| 152 | + | fontFamily: string; | |
| 153 | + | fontSize: number; | |
| 154 | + | color?: string; | |
| 155 | + | gradient?: string[]; | |
| 156 | + | bold: boolean; | |
| 157 | + | italic: boolean; | |
| 158 | + | underline: boolean; | |
| 159 | + | strikethrough: boolean; | |
| 160 | + | }; | |
| 161 | + | allowFormatting: boolean; | |
| 162 | + | maxMessageLength: number; | |
| 163 | + | createdAt: number; | |
| 164 | + | updatedAt: number; | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // ============ TEXT PREFERENCES ============ | |
| 168 | + | ||
| 169 | + | export const textPreferences = { | |
| 170 | + | get: (userId: string) => | |
| 171 | + | convexFetch<ConvexTextPreferences | null>('/api/textPreferences/get', { userId }), | |
| 172 | + | ||
| 173 | + | save: ( | |
| 174 | + | userId: string, | |
| 175 | + | defaultStyle: { | |
| 176 | + | fontFamily: string; | |
| 177 | + | fontSize: number; | |
| 178 | + | color?: string; | |
| 179 | + | gradient?: string[]; | |
| 180 | + | bold: boolean; | |
| 181 | + | italic: boolean; | |
| 182 | + | underline: boolean; | |
| 183 | + | strikethrough: boolean; | |
| 184 | + | }, | |
| 185 | + | allowFormatting: boolean, | |
| 186 | + | maxMessageLength: number | |
| 187 | + | ) => | |
| 188 | + | convexFetch<{ success: boolean; id: string }>('/api/textPreferences/save', { | |
| 189 | + | userId, | |
| 190 | + | defaultStyle, | |
| 191 | + | allowFormatting, | |
| 192 | + | maxMessageLength | |
| 193 | + | }) | |
| 194 | + | }; | |
| 195 | + | ||
| 196 | + | export default { users, sessions, messages, chatRooms, textPreferences }; | |
D src/lib/db/db.server.ts +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | import { drizzle } from 'drizzle-orm/libsql'; | |
| 2 | - | import { createClient } from '@libsql/client'; | |
| 3 | - | import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private'; | |
| 4 | - | import schema from './schema'; | |
| 5 | - | import { ensureDefaultChatRoom } from '$lib/utils/chat.server'; | |
| 6 | - | import { createLogger } from '$lib/utils/logger.server'; | |
| 7 | - | ||
| 8 | - | const log = createLogger('db-server'); | |
| 9 | - | ||
| 10 | - | const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN }); | |
| 11 | - | const db = drizzle(client, { schema }); | |
| 12 | - | ||
| 13 | - | // Initialize database with required data | |
| 14 | - | export const initializeDatabase = async () => { | |
| 15 | - | log.debug('Initializing database...'); | |
| 16 | - | try { | |
| 17 | - | await ensureDefaultChatRoom(db); | |
| 18 | - | log.debug('Database initialization complete'); | |
| 19 | - | } catch (error) { | |
| 20 | - | log.error('Error initializing database:', { | |
| 21 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 22 | - | }); | |
| 23 | - | throw error; | |
| 24 | - | } | |
| 25 | - | }; | |
| 26 | - | ||
| 27 | - | // Call initialization when the module is loaded | |
| 28 | - | initializeDatabase().catch((error) => { | |
| 29 | - | log.error('Failed to initialize database:', { | |
| 30 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 31 | - | }); | |
| 32 | - | }); | |
| 33 | - | ||
| 34 | - | export const testConnection = async () => { | |
| 35 | - | const result = await db.query.users.findMany(); | |
| 36 | - | log.debug('Test connection result:', result); | |
| 37 | - | }; | |
| 38 | - | ||
| 39 | - | export default db; | |
D src/lib/db/flush.ts +0 -54
| @@ -1,54 +0,0 @@ | |||
| 1 | - | import { createClient } from '@libsql/client'; | |
| 2 | - | import * as dotenv from 'dotenv'; | |
| 3 | - | ||
| 4 | - | // Load environment variables from .env file | |
| 5 | - | dotenv.config(); | |
| 6 | - | ||
| 7 | - | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | |
| 8 | - | ||
| 9 | - | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | |
| 10 | - | throw new Error( | |
| 11 | - | 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN' | |
| 12 | - | ); | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | async function flushDatabase() { | |
| 16 | - | console.debug('Starting database content flush...'); | |
| 17 | - | ||
| 18 | - | const client = createClient({ | |
| 19 | - | url: TURSO_DATABASE_URL as string, | |
| 20 | - | authToken: TURSO_AUTH_TOKEN as string | |
| 21 | - | }); | |
| 22 | - | ||
| 23 | - | try { | |
| 24 | - | // Clear tables in correct order to respect foreign key constraints | |
| 25 | - | console.debug('Clearing messages table...'); | |
| 26 | - | await client.execute('DELETE FROM messages'); | |
| 27 | - | ||
| 28 | - | console.debug('Clearing sessions table...'); | |
| 29 | - | await client.execute('DELETE FROM sessions'); | |
| 30 | - | ||
| 31 | - | console.debug('Clearing chat rooms table...'); | |
| 32 | - | await client.execute('DELETE FROM chat_rooms'); | |
| 33 | - | ||
| 34 | - | console.debug('Clearing users table...'); | |
| 35 | - | await client.execute('DELETE FROM users'); | |
| 36 | - | ||
| 37 | - | console.debug('Database content flush completed successfully'); | |
| 38 | - | } catch (error) { | |
| 39 | - | console.error('Error flushing database content:', error); | |
| 40 | - | throw error; | |
| 41 | - | } finally { | |
| 42 | - | await client.close(); | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // Execute if this file is run directly | |
| 47 | - | if (process.argv[1] === import.meta.url) { | |
| 48 | - | flushDatabase() | |
| 49 | - | .then(() => process.exit(0)) | |
| 50 | - | .catch((error) => { | |
| 51 | - | console.error('Failed to flush database content:', error); | |
| 52 | - | process.exit(1); | |
| 53 | - | }); | |
| 54 | - | } | |
D src/lib/db/schema.ts +0 -138
| @@ -1,138 +0,0 @@ | |||
| 1 | - | import { sqliteTable as table } from 'drizzle-orm/sqlite-core'; | |
| 2 | - | import * as t from 'drizzle-orm/sqlite-core'; | |
| 3 | - | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from '../types/chat'; | |
| 4 | - | import { v4 as uuidv4 } from 'uuid'; | |
| 5 | - | import { eq } from 'drizzle-orm'; | |
| 6 | - | ||
| 7 | - | export const users = table('users', { | |
| 8 | - | id: t | |
| 9 | - | .text('id') | |
| 10 | - | .primaryKey() | |
| 11 | - | .$default(() => uuidv4()), | |
| 12 | - | password: t.text('password').notNull(), | |
| 13 | - | nickname: t.text('nickname').notNull(), | |
| 14 | - | status: t.text('status').notNull().$type<UserStatus>().default('offline'), | |
| 15 | - | avatarUrl: t.text('avatar_url'), | |
| 16 | - | createdAt: t.integer('created_at').notNull(), | |
| 17 | - | lastSeen: t.integer('last_seen') | |
| 18 | - | }); | |
| 19 | - | ||
| 20 | - | export const sessions = table('sessions', { | |
| 21 | - | id: t.text('id').primaryKey(), | |
| 22 | - | userId: t | |
| 23 | - | .text('user_id') | |
| 24 | - | .references(() => users.id) | |
| 25 | - | .notNull(), | |
| 26 | - | expiresAt: t.integer('expires_at').notNull(), | |
| 27 | - | createdAt: t.integer('created_at').notNull() | |
| 28 | - | }); | |
| 29 | - | ||
| 30 | - | export const chatRooms = table('chat_rooms', { | |
| 31 | - | id: t | |
| 32 | - | .text('id') | |
| 33 | - | .primaryKey() | |
| 34 | - | .$default(() => uuidv4()), | |
| 35 | - | name: t.text('name'), | |
| 36 | - | type: t.text('type').$type<'direct' | 'group'>().notNull().default('direct'), | |
| 37 | - | createdAt: t.integer('created_at').notNull() | |
| 38 | - | }); | |
| 39 | - | ||
| 40 | - | export const messages = table('messages', { | |
| 41 | - | id: t | |
| 42 | - | .text('id') | |
| 43 | - | .primaryKey() | |
| 44 | - | .$default(() => uuidv4()), | |
| 45 | - | chatRoomId: t | |
| 46 | - | .text('chat_room_id') | |
| 47 | - | .references(() => chatRooms.id) | |
| 48 | - | .notNull(), | |
| 49 | - | senderId: t | |
| 50 | - | .text('sender_id') | |
| 51 | - | .references(() => users.id) | |
| 52 | - | .notNull(), | |
| 53 | - | content: t.text('content').notNull(), | |
| 54 | - | type: t.text('type').$type<MessageType>().notNull().default('chat'), | |
| 55 | - | timestamp: t.integer('timestamp').notNull(), | |
| 56 | - | styleData: t.text('style_data'), // JSON string of TextStyle object | |
| 57 | - | hasFormatting: t.integer('has_formatting', { mode: 'boolean' }).default(false) // Boolean flag | |
| 58 | - | }); | |
| 59 | - | ||
| 60 | - | export const userTextPreferences = table('user_text_preferences', { | |
| 61 | - | userId: t | |
| 62 | - | .text('user_id') | |
| 63 | - | .references(() => users.id) | |
| 64 | - | .primaryKey(), | |
| 65 | - | defaultFontFamily: t.text('default_font_family').default('tahoma'), | |
| 66 | - | defaultFontSize: t.integer('default_font_size').default(14), | |
| 67 | - | defaultColor: t.text('default_color').default('black'), | |
| 68 | - | allowFormatting: t.integer('allow_formatting', { mode: 'boolean' }).default(true), | |
| 69 | - | maxMessageLength: t.integer('max_message_length').default(500), | |
| 70 | - | stylePresets: t.text('style_presets'), // JSON array of saved styles | |
| 71 | - | createdAt: t.integer('created_at').notNull(), | |
| 72 | - | updatedAt: t.integer('updated_at').notNull() | |
| 73 | - | }); | |
| 74 | - | ||
| 75 | - | // Validate schema types against interfaces | |
| 76 | - | export type Users = typeof users.$inferSelect; | |
| 77 | - | export type ChatRooms = typeof chatRooms.$inferSelect; | |
| 78 | - | export type Messages = typeof messages.$inferSelect; | |
| 79 | - | export type Sessions = typeof sessions.$inferSelect; | |
| 80 | - | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | |
| 81 | - | // These type assertions will fail if the schema doesn't match the interfaces | |
| 82 | - | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | |
| 83 | - | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & | |
| 84 | - | Omit<ChatRooms, keyof ChatRoom>; | |
| 85 | - | export type _MessagesValidation = Omit<Message, keyof Messages> & Omit<Messages, keyof Message>; | |
| 86 | - | export type _SessionsValidation = Omit<Session, keyof Sessions> & Omit<Sessions, keyof Session>; | |
| 87 | - | ||
| 88 | - | export const userView = t.sqliteView('user_view').as((qb) => | |
| 89 | - | qb | |
| 90 | - | .select({ | |
| 91 | - | id: users.id, | |
| 92 | - | nickname: users.nickname, | |
| 93 | - | status: users.status, | |
| 94 | - | avatarUrl: users.avatarUrl, | |
| 95 | - | lastSeen: users.lastSeen | |
| 96 | - | }) | |
| 97 | - | .from(users) | |
| 98 | - | ); | |
| 99 | - | ||
| 100 | - | export const chatRoomView = t.sqliteView('chat_room_view').as((qb) => | |
| 101 | - | qb | |
| 102 | - | .select({ | |
| 103 | - | id: chatRooms.id, | |
| 104 | - | name: chatRooms.name, | |
| 105 | - | type: chatRooms.type, | |
| 106 | - | createdAt: chatRooms.createdAt | |
| 107 | - | }) | |
| 108 | - | .from(chatRooms) | |
| 109 | - | ); | |
| 110 | - | ||
| 111 | - | export const messageView = t.sqliteView('message_view').as((qb) => | |
| 112 | - | qb | |
| 113 | - | .select({ | |
| 114 | - | id: messages.id, | |
| 115 | - | content: messages.content, | |
| 116 | - | type: messages.type, | |
| 117 | - | timestamp: messages.timestamp, | |
| 118 | - | sender: users.nickname, | |
| 119 | - | chatRoom: chatRooms.name | |
| 120 | - | }) | |
| 121 | - | .from(messages) | |
| 122 | - | .leftJoin(users, eq(messages.senderId, users.id)) | |
| 123 | - | .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id)) | |
| 124 | - | ); | |
| 125 | - | ||
| 126 | - | // Create a schema object with all our tables and views | |
| 127 | - | const schema = { | |
| 128 | - | users, | |
| 129 | - | sessions, | |
| 130 | - | chatRooms, | |
| 131 | - | messages, | |
| 132 | - | userTextPreferences, | |
| 133 | - | userView, | |
| 134 | - | chatRoomView, | |
| 135 | - | messageView | |
| 136 | - | }; | |
| 137 | - | ||
| 138 | - | export default schema; | |
D src/lib/sse/MessageQueue.ts +0 -227
| @@ -1,227 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Server-side Message Queue for SSE Catch-up | |
| 3 | - | * | |
| 4 | - | * Stores recent messages to allow clients to catch up after reconnection | |
| 5 | - | * using the Last-Event-ID mechanism. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import { SSE_CONFIG } from './config'; | |
| 9 | - | ||
| 10 | - | /** | |
| 11 | - | * Queued message structure | |
| 12 | - | */ | |
| 13 | - | export interface QueuedMessage { | |
| 14 | - | /** Unique event ID for Last-Event-ID support */ | |
| 15 | - | id: string; | |
| 16 | - | /** Event type (chatMessage, userStatusUpdate, etc.) */ | |
| 17 | - | event: string; | |
| 18 | - | /** Event payload */ | |
| 19 | - | data: unknown; | |
| 20 | - | /** Timestamp for TTL expiration */ | |
| 21 | - | timestamp: number; | |
| 22 | - | /** Chat room ID for filtering */ | |
| 23 | - | roomId: string; | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | /** | |
| 27 | - | * Message Queue for SSE catch-up support | |
| 28 | - | * | |
| 29 | - | * Maintains a queue of recent messages per room to allow clients | |
| 30 | - | * to catch up after reconnection using Last-Event-ID. | |
| 31 | - | */ | |
| 32 | - | class MessageQueue { | |
| 33 | - | private queues: Map<string, QueuedMessage[]> = new Map(); | |
| 34 | - | private readonly ttl: number; | |
| 35 | - | private readonly maxSize: number; | |
| 36 | - | private cleanupInterval: ReturnType<typeof setInterval> | null = null; | |
| 37 | - | ||
| 38 | - | constructor( | |
| 39 | - | ttl: number = SSE_CONFIG.MESSAGE_QUEUE_TTL, | |
| 40 | - | maxSize: number = SSE_CONFIG.MESSAGE_QUEUE_MAX_SIZE | |
| 41 | - | ) { | |
| 42 | - | this.ttl = ttl; | |
| 43 | - | this.maxSize = maxSize; | |
| 44 | - | ||
| 45 | - | // Start periodic cleanup | |
| 46 | - | this.startCleanup(); | |
| 47 | - | console.log('[MessageQueue] Initialized:', { ttl, maxSize }); | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | /** | |
| 51 | - | * Add a message to the queue | |
| 52 | - | * @returns The generated event ID | |
| 53 | - | */ | |
| 54 | - | enqueue(roomId: string, event: string, data: unknown): string { | |
| 55 | - | const id = crypto.randomUUID(); | |
| 56 | - | const message: QueuedMessage = { | |
| 57 | - | id, | |
| 58 | - | event, | |
| 59 | - | data, | |
| 60 | - | timestamp: Date.now(), | |
| 61 | - | roomId | |
| 62 | - | }; | |
| 63 | - | ||
| 64 | - | let queue = this.queues.get(roomId); | |
| 65 | - | if (!queue) { | |
| 66 | - | queue = []; | |
| 67 | - | this.queues.set(roomId, queue); | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | queue.push(message); | |
| 71 | - | ||
| 72 | - | // Trim if over max size (keep newest) | |
| 73 | - | if (queue.length > this.maxSize) { | |
| 74 | - | const trimmed = queue.length - this.maxSize; | |
| 75 | - | this.queues.set(roomId, queue.slice(-this.maxSize)); | |
| 76 | - | console.log('[MessageQueue] Queue trimmed:', { roomId, trimmed }); | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | return id; | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | /** | |
| 83 | - | * Get messages since a given event ID | |
| 84 | - | * If the ID is not found, returns the most recent messages | |
| 85 | - | */ | |
| 86 | - | getMessagesSince(roomId: string, lastEventId: string): QueuedMessage[] { | |
| 87 | - | const queue = this.queues.get(roomId); | |
| 88 | - | if (!queue || queue.length === 0) { | |
| 89 | - | return []; | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | const lastIndex = queue.findIndex((m) => m.id === lastEventId); | |
| 93 | - | ||
| 94 | - | if (lastIndex === -1) { | |
| 95 | - | // ID not found - return recent messages as fallback | |
| 96 | - | console.log('[MessageQueue] Last-Event-ID not found, returning recent:', { | |
| 97 | - | roomId, | |
| 98 | - | lastEventId, | |
| 99 | - | returning: Math.min(50, queue.length) | |
| 100 | - | }); | |
| 101 | - | return queue.slice(-50); | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // Return messages after the last seen ID | |
| 105 | - | const missed = queue.slice(lastIndex + 1); | |
| 106 | - | if (missed.length > 0) { | |
| 107 | - | console.log('[MessageQueue] Catch-up messages:', { | |
| 108 | - | roomId, | |
| 109 | - | lastEventId, | |
| 110 | - | missedCount: missed.length | |
| 111 | - | }); | |
| 112 | - | } | |
| 113 | - | return missed; | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | /** | |
| 117 | - | * Get all messages for a room (for initial load) | |
| 118 | - | */ | |
| 119 | - | getMessages(roomId: string, limit: number = 50): QueuedMessage[] { | |
| 120 | - | const queue = this.queues.get(roomId); | |
| 121 | - | if (!queue) { | |
| 122 | - | return []; | |
| 123 | - | } | |
| 124 | - | return queue.slice(-limit); | |
| 125 | - | } | |
| 126 | - | ||
| 127 | - | /** | |
| 128 | - | * Get the latest event ID for a room | |
| 129 | - | */ | |
| 130 | - | getLatestEventId(roomId: string): string | null { | |
| 131 | - | const queue = this.queues.get(roomId); | |
| 132 | - | if (!queue || queue.length === 0) { | |
| 133 | - | return null; | |
| 134 | - | } | |
| 135 | - | return queue[queue.length - 1].id; | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | /** | |
| 139 | - | * Check if an event ID exists in the queue | |
| 140 | - | */ | |
| 141 | - | hasEventId(roomId: string, eventId: string): boolean { | |
| 142 | - | const queue = this.queues.get(roomId); | |
| 143 | - | if (!queue) { | |
| 144 | - | return false; | |
| 145 | - | } | |
| 146 | - | return queue.some((m) => m.id === eventId); | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | /** | |
| 150 | - | * Remove expired messages from all queues | |
| 151 | - | */ | |
| 152 | - | cleanup(): number { | |
| 153 | - | const now = Date.now(); | |
| 154 | - | let totalRemoved = 0; | |
| 155 | - | ||
| 156 | - | for (const [roomId, queue] of this.queues.entries()) { | |
| 157 | - | const originalLength = queue.length; | |
| 158 | - | const filtered = queue.filter((m) => now - m.timestamp < this.ttl); | |
| 159 | - | ||
| 160 | - | if (filtered.length !== originalLength) { | |
| 161 | - | totalRemoved += originalLength - filtered.length; | |
| 162 | - | if (filtered.length === 0) { | |
| 163 | - | this.queues.delete(roomId); | |
| 164 | - | } else { | |
| 165 | - | this.queues.set(roomId, filtered); | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | if (totalRemoved > 0) { | |
| 171 | - | console.log('[MessageQueue] Cleanup completed:', { | |
| 172 | - | removed: totalRemoved, | |
| 173 | - | activeRooms: this.queues.size | |
| 174 | - | }); | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | return totalRemoved; | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | /** | |
| 181 | - | * Start periodic cleanup interval | |
| 182 | - | */ | |
| 183 | - | private startCleanup(): void { | |
| 184 | - | // Cleanup every minute | |
| 185 | - | this.cleanupInterval = setInterval(() => { | |
| 186 | - | this.cleanup(); | |
| 187 | - | }, 60_000); | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | /** | |
| 191 | - | * Stop cleanup interval (for shutdown) | |
| 192 | - | */ | |
| 193 | - | stopCleanup(): void { | |
| 194 | - | if (this.cleanupInterval) { | |
| 195 | - | clearInterval(this.cleanupInterval); | |
| 196 | - | this.cleanupInterval = null; | |
| 197 | - | } | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | /** | |
| 201 | - | * Clear all queues (for testing or reset) | |
| 202 | - | */ | |
| 203 | - | clear(): void { | |
| 204 | - | this.queues.clear(); | |
| 205 | - | console.log('[MessageQueue] All queues cleared'); | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | /** | |
| 209 | - | * Get statistics about the queue | |
| 210 | - | */ | |
| 211 | - | getStats(): { rooms: number; totalMessages: number } { | |
| 212 | - | let totalMessages = 0; | |
| 213 | - | for (const queue of this.queues.values()) { | |
| 214 | - | totalMessages += queue.length; | |
| 215 | - | } | |
| 216 | - | return { | |
| 217 | - | rooms: this.queues.size, | |
| 218 | - | totalMessages | |
| 219 | - | }; | |
| 220 | - | } | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | // Singleton instance | |
| 224 | - | export const messageQueue = new MessageQueue(); | |
| 225 | - | ||
| 226 | - | // Export class for testing | |
| 227 | - | export { MessageQueue }; | |
D src/lib/sse/SSEClient.svelte.ts +0 -322
| @@ -1,322 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * SSE Client - Client-side SSE connection manager | |
| 3 | - | * | |
| 4 | - | * Handles EventSource connections with: | |
| 5 | - | * - Unlimited reconnection with exponential backoff + jitter | |
| 6 | - | * - Heartbeat monitoring and acknowledgment | |
| 7 | - | * - Last-Event-ID tracking for catch-up | |
| 8 | - | * - Reactive state using Svelte 5 runes | |
| 9 | - | */ | |
| 10 | - | ||
| 11 | - | import { SSE_CONFIG, type ConnectionStatus } from './config'; | |
| 12 | - | import type { SSEEventMap } from './SSEEventEmitter'; | |
| 13 | - | ||
| 14 | - | /** | |
| 15 | - | * Configuration for SSE Client | |
| 16 | - | */ | |
| 17 | - | export interface SSEClientConfig { | |
| 18 | - | /** SSE endpoint URL */ | |
| 19 | - | url: string; | |
| 20 | - | /** Callback for incoming messages */ | |
| 21 | - | onMessage: <T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) => void; | |
| 22 | - | /** Callback for connection status changes */ | |
| 23 | - | onStatusChange?: (status: ConnectionStatus) => void; | |
| 24 | - | /** Callback for errors */ | |
| 25 | - | onError?: (error: string) => void; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | /** | |
| 29 | - | * SSE Client with Svelte 5 reactive state | |
| 30 | - | */ | |
| 31 | - | export class SSEClient { | |
| 32 | - | // Configuration | |
| 33 | - | private config: SSEClientConfig; | |
| 34 | - | ||
| 35 | - | // Connection state | |
| 36 | - | private eventSource: EventSource | null = null; | |
| 37 | - | private _connectionId: string | null = null; | |
| 38 | - | ||
| 39 | - | // Reactive state using Svelte 5 runes | |
| 40 | - | private _status = $state<ConnectionStatus>('disconnected'); | |
| 41 | - | private _lastEventId = $state<string | null>(null); | |
| 42 | - | private _error = $state<string | null>(null); | |
| 43 | - | private _reconnectAttempts = $state(0); | |
| 44 | - | ||
| 45 | - | // Timers | |
| 46 | - | private heartbeatTimer: ReturnType<typeof setTimeout> | null = null; | |
| 47 | - | private reconnectTimer: ReturnType<typeof setTimeout> | null = null; | |
| 48 | - | ||
| 49 | - | // Derived state | |
| 50 | - | public status = $derived(this._status); | |
| 51 | - | public lastEventId = $derived(this._lastEventId); | |
| 52 | - | public error = $derived(this._error); | |
| 53 | - | public reconnectAttempts = $derived(this._reconnectAttempts); | |
| 54 | - | public isConnected = $derived(this._status === 'connected'); | |
| 55 | - | public isReconnecting = $derived(this._status === 'reconnecting'); | |
| 56 | - | ||
| 57 | - | constructor(config: SSEClientConfig) { | |
| 58 | - | this.config = config; | |
| 59 | - | console.debug('[SSEClient] Initialized'); | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | /** | |
| 63 | - | * Connect to the SSE endpoint | |
| 64 | - | */ | |
| 65 | - | connect(): void { | |
| 66 | - | if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) { | |
| 67 | - | console.debug('[SSEClient] Already connected or connecting'); | |
| 68 | - | return; | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | this.setStatus('connecting'); | |
| 72 | - | this._error = null; | |
| 73 | - | ||
| 74 | - | try { | |
| 75 | - | // EventSource automatically sends Last-Event-ID header on reconnection | |
| 76 | - | this.eventSource = new EventSource(this.config.url, { withCredentials: true }); | |
| 77 | - | ||
| 78 | - | this.eventSource.onopen = () => { | |
| 79 | - | console.debug('[SSEClient] Connection established'); | |
| 80 | - | this.setStatus('connected'); | |
| 81 | - | this._reconnectAttempts = 0; | |
| 82 | - | this._error = null; | |
| 83 | - | this.startHeartbeatMonitor(); | |
| 84 | - | }; | |
| 85 | - | ||
| 86 | - | this.eventSource.onerror = (event) => { | |
| 87 | - | console.debug('[SSEClient] Connection error:', event); | |
| 88 | - | ||
| 89 | - | if (this.eventSource?.readyState === EventSource.CLOSED) { | |
| 90 | - | this.handleDisconnection(); | |
| 91 | - | } else if (this.eventSource?.readyState === EventSource.CONNECTING) { | |
| 92 | - | this.setStatus('reconnecting'); | |
| 93 | - | } | |
| 94 | - | }; | |
| 95 | - | ||
| 96 | - | // Set up event listeners | |
| 97 | - | this.setupEventListeners(); | |
| 98 | - | } catch (error) { | |
| 99 | - | console.error('[SSEClient] Failed to create EventSource:', error); | |
| 100 | - | this._error = 'Failed to connect'; | |
| 101 | - | this.handleDisconnection(); | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | /** | |
| 106 | - | * Disconnect from SSE | |
| 107 | - | */ | |
| 108 | - | disconnect(): void { | |
| 109 | - | console.debug('[SSEClient] Disconnecting'); | |
| 110 | - | this.cleanup(); | |
| 111 | - | this.setStatus('disconnected'); | |
| 112 | - | this._connectionId = null; | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | /** | |
| 116 | - | * Get the current connection ID | |
| 117 | - | */ | |
| 118 | - | getConnectionId(): string | null { | |
| 119 | - | return this._connectionId; | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | /** | |
| 123 | - | * Set up event listeners for SSE events | |
| 124 | - | */ | |
| 125 | - | private setupEventListeners(): void { | |
| 126 | - | if (!this.eventSource) return; | |
| 127 | - | ||
| 128 | - | // Track last event ID from all events | |
| 129 | - | const trackEventId = (event: MessageEvent) => { | |
| 130 | - | if (event.lastEventId) { | |
| 131 | - | this._lastEventId = event.lastEventId; | |
| 132 | - | } | |
| 133 | - | }; | |
| 134 | - | ||
| 135 | - | // Chat message events | |
| 136 | - | this.eventSource.addEventListener('chatMessage', (event: MessageEvent) => { | |
| 137 | - | trackEventId(event); | |
| 138 | - | try { | |
| 139 | - | const data = JSON.parse(event.data); | |
| 140 | - | this.config.onMessage('chatMessage', data); | |
| 141 | - | } catch (error) { | |
| 142 | - | console.error('[SSEClient] Error parsing chatMessage:', error); | |
| 143 | - | } | |
| 144 | - | }); | |
| 145 | - | ||
| 146 | - | // User status update events | |
| 147 | - | this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => { | |
| 148 | - | trackEventId(event); | |
| 149 | - | try { | |
| 150 | - | const data = JSON.parse(event.data); | |
| 151 | - | this.config.onMessage('userStatusUpdate', data); | |
| 152 | - | } catch (error) { | |
| 153 | - | console.error('[SSEClient] Error parsing userStatusUpdate:', error); | |
| 154 | - | } | |
| 155 | - | }); | |
| 156 | - | ||
| 157 | - | // Buddy list update events | |
| 158 | - | this.eventSource.addEventListener('buddyListUpdate', (event: MessageEvent) => { | |
| 159 | - | trackEventId(event); | |
| 160 | - | try { | |
| 161 | - | const data = JSON.parse(event.data); | |
| 162 | - | this.config.onMessage('buddyListUpdate', data); | |
| 163 | - | } catch (error) { | |
| 164 | - | console.error('[SSEClient] Error parsing buddyListUpdate:', error); | |
| 165 | - | } | |
| 166 | - | }); | |
| 167 | - | ||
| 168 | - | // Heartbeat events | |
| 169 | - | this.eventSource.addEventListener('heartbeat', (event: MessageEvent) => { | |
| 170 | - | trackEventId(event); | |
| 171 | - | try { | |
| 172 | - | const data = JSON.parse(event.data); | |
| 173 | - | this._connectionId = data.connectionId; | |
| 174 | - | this.acknowledgeHeartbeat(data.connectionId); | |
| 175 | - | this.resetHeartbeatMonitor(); | |
| 176 | - | } catch (error) { | |
| 177 | - | console.error('[SSEClient] Error parsing heartbeat:', error); | |
| 178 | - | } | |
| 179 | - | }); | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | /** | |
| 183 | - | * Start monitoring for heartbeat timeout | |
| 184 | - | */ | |
| 185 | - | private startHeartbeatMonitor(): void { | |
| 186 | - | this.clearHeartbeatTimer(); | |
| 187 | - | ||
| 188 | - | this.heartbeatTimer = setTimeout(() => { | |
| 189 | - | console.warn('[SSEClient] Heartbeat timeout - connection may be stale'); | |
| 190 | - | // Force reconnection if no heartbeat received | |
| 191 | - | if (this.eventSource) { | |
| 192 | - | this.eventSource.close(); | |
| 193 | - | } | |
| 194 | - | this.handleDisconnection(); | |
| 195 | - | }, SSE_CONFIG.HEARTBEAT_TIMEOUT); | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | /** | |
| 199 | - | * Reset the heartbeat monitor (called when heartbeat received) | |
| 200 | - | */ | |
| 201 | - | private resetHeartbeatMonitor(): void { | |
| 202 | - | this.startHeartbeatMonitor(); | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | /** | |
| 206 | - | * Clear heartbeat timer | |
| 207 | - | */ | |
| 208 | - | private clearHeartbeatTimer(): void { | |
| 209 | - | if (this.heartbeatTimer) { | |
| 210 | - | clearTimeout(this.heartbeatTimer); | |
| 211 | - | this.heartbeatTimer = null; | |
| 212 | - | } | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | /** | |
| 216 | - | * Send heartbeat acknowledgment to server | |
| 217 | - | */ | |
| 218 | - | private async acknowledgeHeartbeat(connectionId: string): Promise<void> { | |
| 219 | - | try { | |
| 220 | - | const response = await fetch('/api/sse/heartbeat', { | |
| 221 | - | method: 'POST', | |
| 222 | - | headers: { 'Content-Type': 'application/json' }, | |
| 223 | - | credentials: 'include', | |
| 224 | - | body: JSON.stringify({ connectionId }) | |
| 225 | - | }); | |
| 226 | - | ||
| 227 | - | if (!response.ok) { | |
| 228 | - | console.warn('[SSEClient] Heartbeat acknowledgment failed:', response.status); | |
| 229 | - | } | |
| 230 | - | } catch (error) { | |
| 231 | - | console.warn('[SSEClient] Failed to acknowledge heartbeat:', error); | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | /** | |
| 236 | - | * Handle disconnection with reconnection logic | |
| 237 | - | */ | |
| 238 | - | private handleDisconnection(): void { | |
| 239 | - | this.cleanup(); | |
| 240 | - | this.setStatus('reconnecting'); | |
| 241 | - | this._reconnectAttempts++; | |
| 242 | - | ||
| 243 | - | const delay = this.calculateBackoff(); | |
| 244 | - | console.debug(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`); | |
| 245 | - | ||
| 246 | - | this.reconnectTimer = setTimeout(() => { | |
| 247 | - | this.connect(); | |
| 248 | - | }, delay); | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | /** | |
| 252 | - | * Calculate reconnection delay with exponential backoff and jitter | |
| 253 | - | * Never gives up - always tries to reconnect | |
| 254 | - | */ | |
| 255 | - | private calculateBackoff(): number { | |
| 256 | - | const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY; | |
| 257 | - | const max = SSE_CONFIG.MAX_RECONNECT_DELAY; | |
| 258 | - | ||
| 259 | - | // Exponential backoff: 2^attempts * base, capped at max | |
| 260 | - | const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max); | |
| 261 | - | ||
| 262 | - | // Add random jitter (0-25% of delay) to prevent thundering herd | |
| 263 | - | const jitter = exponentialDelay * Math.random() * 0.25; | |
| 264 | - | ||
| 265 | - | return Math.floor(exponentialDelay + jitter); | |
| 266 | - | } | |
| 267 | - | ||
| 268 | - | /** | |
| 269 | - | * Get the estimated next reconnect delay in seconds | |
| 270 | - | * Returns null if not reconnecting | |
| 271 | - | */ | |
| 272 | - | getReconnectDelay(): number | null { | |
| 273 | - | if (this._status !== 'reconnecting') { | |
| 274 | - | return null; | |
| 275 | - | } | |
| 276 | - | // Calculate the expected delay for the current attempt | |
| 277 | - | const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY; | |
| 278 | - | const max = SSE_CONFIG.MAX_RECONNECT_DELAY; | |
| 279 | - | const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max); | |
| 280 | - | return Math.ceil(exponentialDelay / 1000); // Convert to seconds | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | /** | |
| 284 | - | * Clean up resources | |
| 285 | - | */ | |
| 286 | - | private cleanup(): void { | |
| 287 | - | this.clearHeartbeatTimer(); | |
| 288 | - | ||
| 289 | - | if (this.reconnectTimer) { | |
| 290 | - | clearTimeout(this.reconnectTimer); | |
| 291 | - | this.reconnectTimer = null; | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | if (this.eventSource) { | |
| 295 | - | this.eventSource.close(); | |
| 296 | - | this.eventSource = null; | |
| 297 | - | } | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | /** | |
| 301 | - | * Set connection status and notify listener | |
| 302 | - | */ | |
| 303 | - | private setStatus(status: ConnectionStatus): void { | |
| 304 | - | this._status = status; | |
| 305 | - | this.config.onStatusChange?.(status); | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | /** | |
| 309 | - | * Destroy the client completely | |
| 310 | - | */ | |
| 311 | - | destroy(): void { | |
| 312 | - | console.debug('[SSEClient] Destroying'); | |
| 313 | - | this.disconnect(); | |
| 314 | - | } | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | /** | |
| 318 | - | * Factory function to create an SSE client | |
| 319 | - | */ | |
| 320 | - | export function createSSEClient(config: SSEClientConfig): SSEClient { | |
| 321 | - | return new SSEClient(config); | |
| 322 | - | } | |
D src/lib/sse/SSEEventEmitter.ts +0 -188
| @@ -1,188 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Type-safe SSE Event Emitter | |
| 3 | - | * | |
| 4 | - | * Provides typed event emission for SSE broadcasts with automatic | |
| 5 | - | * listener cleanup and memory leak prevention. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import type { Message, SafeUser, UserStatus } from '../types/chat'; | |
| 9 | - | ||
| 10 | - | /** | |
| 11 | - | * SSE Event Type Map - defines the payload for each event type | |
| 12 | - | */ | |
| 13 | - | export interface SSEEventMap { | |
| 14 | - | chatMessage: Message; | |
| 15 | - | userStatusUpdate: { | |
| 16 | - | userId: string; | |
| 17 | - | status: UserStatus; | |
| 18 | - | lastSeen: number; | |
| 19 | - | }; | |
| 20 | - | buddyListUpdate: SafeUser[]; | |
| 21 | - | heartbeat: { | |
| 22 | - | connectionId: string; | |
| 23 | - | timestamp: number; | |
| 24 | - | serverTime: string; | |
| 25 | - | }; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | /** | |
| 29 | - | * Generic SSE event structure sent over the wire | |
| 30 | - | */ | |
| 31 | - | export interface SSEEvent<T extends keyof SSEEventMap = keyof SSEEventMap> { | |
| 32 | - | type: T; | |
| 33 | - | data: SSEEventMap[T]; | |
| 34 | - | id?: string; // Event ID for Last-Event-ID support | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | /** | |
| 38 | - | * Listener function type for SSE events | |
| 39 | - | */ | |
| 40 | - | type SSEListener = (event: SSEEvent) => void; | |
| 41 | - | ||
| 42 | - | /** | |
| 43 | - | * Type-safe SSE Event Emitter with automatic cleanup | |
| 44 | - | */ | |
| 45 | - | class SSEEventEmitter { | |
| 46 | - | private listeners: Map<string, Set<SSEListener>> = new Map(); | |
| 47 | - | private maxListeners: number = 100; | |
| 48 | - | private listenerCount: number = 0; | |
| 49 | - | ||
| 50 | - | constructor() { | |
| 51 | - | console.log('[SSE-Emitter] Initialized'); | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | /** | |
| 55 | - | * Emit an SSE event to all listeners | |
| 56 | - | */ | |
| 57 | - | emit<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], id?: string): void { | |
| 58 | - | const event: SSEEvent<T> = { type, data, id }; | |
| 59 | - | ||
| 60 | - | // Get listeners for the 'sse' channel (main broadcast channel) | |
| 61 | - | const eventListeners = this.listeners.get('sse'); | |
| 62 | - | if (eventListeners && eventListeners.size > 0) { | |
| 63 | - | // Only log non-frequent event types | |
| 64 | - | if (!['userStatusUpdate', 'chatMessage', 'heartbeat'].includes(type)) { | |
| 65 | - | console.log('[SSE-Emitter] Broadcasting event:', { | |
| 66 | - | type, | |
| 67 | - | listenersCount: eventListeners.size | |
| 68 | - | }); | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // Iterate over a copy to allow removal during iteration | |
| 72 | - | for (const listener of [...eventListeners]) { | |
| 73 | - | try { | |
| 74 | - | listener(event as SSEEvent); | |
| 75 | - | } catch (error) { | |
| 76 | - | console.error('[SSE-Emitter] Error in listener:', error); | |
| 77 | - | // Remove broken listener | |
| 78 | - | this.removeListener('sse', listener); | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | /** | |
| 85 | - | * Add a listener for SSE events | |
| 86 | - | * Returns a cleanup function for easy removal | |
| 87 | - | */ | |
| 88 | - | addListener(channel: string, listener: SSEListener): () => void { | |
| 89 | - | if (!this.listeners.has(channel)) { | |
| 90 | - | this.listeners.set(channel, new Set()); | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | const channelListeners = this.listeners.get(channel)!; | |
| 94 | - | ||
| 95 | - | if (channelListeners.size >= this.maxListeners) { | |
| 96 | - | console.warn( | |
| 97 | - | `[SSE-Emitter] Warning: Channel '${channel}' has reached max listeners (${this.maxListeners})` | |
| 98 | - | ); | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | channelListeners.add(listener); | |
| 102 | - | this.listenerCount++; | |
| 103 | - | ||
| 104 | - | console.log('[SSE-Emitter] Listener added:', { | |
| 105 | - | channel, | |
| 106 | - | totalListeners: channelListeners.size, | |
| 107 | - | globalTotal: this.listenerCount | |
| 108 | - | }); | |
| 109 | - | ||
| 110 | - | // Return cleanup function | |
| 111 | - | return () => this.removeListener(channel, listener); | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | /** | |
| 115 | - | * Remove a specific listener | |
| 116 | - | */ | |
| 117 | - | removeListener(channel: string, listener: SSEListener): boolean { | |
| 118 | - | const channelListeners = this.listeners.get(channel); | |
| 119 | - | if (channelListeners) { | |
| 120 | - | const removed = channelListeners.delete(listener); | |
| 121 | - | if (removed) { | |
| 122 | - | this.listenerCount--; | |
| 123 | - | console.log('[SSE-Emitter] Listener removed:', { | |
| 124 | - | channel, | |
| 125 | - | remainingListeners: channelListeners.size, | |
| 126 | - | globalTotal: this.listenerCount | |
| 127 | - | }); | |
| 128 | - | ||
| 129 | - | // Clean up empty channels | |
| 130 | - | if (channelListeners.size === 0) { | |
| 131 | - | this.listeners.delete(channel); | |
| 132 | - | } | |
| 133 | - | return true; | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | return false; | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | /** | |
| 140 | - | * Remove all listeners for a channel | |
| 141 | - | */ | |
| 142 | - | removeAllListeners(channel?: string): void { | |
| 143 | - | if (channel) { | |
| 144 | - | const channelListeners = this.listeners.get(channel); | |
| 145 | - | if (channelListeners) { | |
| 146 | - | this.listenerCount -= channelListeners.size; | |
| 147 | - | this.listeners.delete(channel); | |
| 148 | - | console.log('[SSE-Emitter] All listeners removed for channel:', channel); | |
| 149 | - | } | |
| 150 | - | } else { | |
| 151 | - | this.listeners.clear(); | |
| 152 | - | this.listenerCount = 0; | |
| 153 | - | console.log('[SSE-Emitter] All listeners removed'); | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | /** | |
| 158 | - | * Get the count of listeners for a channel | |
| 159 | - | */ | |
| 160 | - | getListenerCount(channel?: string): number { | |
| 161 | - | if (channel) { | |
| 162 | - | return this.listeners.get(channel)?.size ?? 0; | |
| 163 | - | } | |
| 164 | - | return this.listenerCount; | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | /** | |
| 168 | - | * Set the maximum number of listeners per channel | |
| 169 | - | */ | |
| 170 | - | setMaxListeners(n: number): void { | |
| 171 | - | this.maxListeners = n; | |
| 172 | - | console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n }); | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | /** | |
| 176 | - | * Check if a channel has any listeners | |
| 177 | - | */ | |
| 178 | - | hasListeners(channel: string): boolean { | |
| 179 | - | const channelListeners = this.listeners.get(channel); | |
| 180 | - | return channelListeners !== undefined && channelListeners.size > 0; | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // Singleton instance | |
| 185 | - | export const sseEmitter = new SSEEventEmitter(); | |
| 186 | - | ||
| 187 | - | // Export type for use in other modules | |
| 188 | - | export type { SSEListener }; | |
D src/lib/sse/SSEManager.ts +0 -381
| @@ -1,381 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * SSE Manager - Central server-side manager for SSE connections | |
| 3 | - | * | |
| 4 | - | * Coordinates connection tracking, message queuing, event broadcasting, | |
| 5 | - | * and heartbeat management. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import { SSE_CONFIG } from './config'; | |
| 9 | - | import { sseEmitter, type SSEEventMap, type SSEEvent } from './SSEEventEmitter'; | |
| 10 | - | import { messageQueue } from './MessageQueue'; | |
| 11 | - | import { createLogger } from '../utils/logger.server'; | |
| 12 | - | ||
| 13 | - | const log = createLogger('sse-manager'); | |
| 14 | - | ||
| 15 | - | /** | |
| 16 | - | * Enhanced connection info with heartbeat tracking | |
| 17 | - | */ | |
| 18 | - | export interface SSEConnection { | |
| 19 | - | /** Unique connection ID */ | |
| 20 | - | connectionId: string; | |
| 21 | - | /** User ID */ | |
| 22 | - | userId: string; | |
| 23 | - | /** When the connection was established */ | |
| 24 | - | connectedAt: number; | |
| 25 | - | /** Last activity timestamp */ | |
| 26 | - | lastActivity: number; | |
| 27 | - | /** Last heartbeat acknowledgment */ | |
| 28 | - | lastHeartbeat: number; | |
| 29 | - | /** Current room ID */ | |
| 30 | - | roomId: string; | |
| 31 | - | /** Stream controller for sending events */ | |
| 32 | - | controller: ReadableStreamDefaultController<Uint8Array> | null; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | /** | |
| 36 | - | * SSE Manager singleton | |
| 37 | - | */ | |
| 38 | - | class SSEManager { | |
| 39 | - | /** Map of connectionId -> SSEConnection */ | |
| 40 | - | private connections: Map<string, SSEConnection> = new Map(); | |
| 41 | - | /** Map of userId -> Set of connectionIds (for multi-tab support) */ | |
| 42 | - | private userConnections: Map<string, Set<string>> = new Map(); | |
| 43 | - | /** Text encoder for SSE output */ | |
| 44 | - | private encoder = new TextEncoder(); | |
| 45 | - | /** Intervals for periodic tasks */ | |
| 46 | - | private heartbeatInterval: ReturnType<typeof setInterval> | null = null; | |
| 47 | - | private cleanupInterval: ReturnType<typeof setInterval> | null = null; | |
| 48 | - | ||
| 49 | - | constructor() { | |
| 50 | - | log.info('SSE Manager initialized'); | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | /** | |
| 54 | - | * Start background tasks (heartbeat, cleanup) | |
| 55 | - | */ | |
| 56 | - | startBackgroundTasks(): void { | |
| 57 | - | if (this.heartbeatInterval || this.cleanupInterval) { | |
| 58 | - | log.debug('Background tasks already running'); | |
| 59 | - | return; | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Send heartbeats to all connections | |
| 63 | - | this.heartbeatInterval = setInterval(() => { | |
| 64 | - | this.sendHeartbeats(); | |
| 65 | - | }, SSE_CONFIG.HEARTBEAT_INTERVAL); | |
| 66 | - | ||
| 67 | - | // Cleanup stale connections | |
| 68 | - | this.cleanupInterval = setInterval(() => { | |
| 69 | - | this.cleanupStaleConnections(); | |
| 70 | - | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 71 | - | ||
| 72 | - | log.info('Background tasks started'); | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | /** | |
| 76 | - | * Stop background tasks | |
| 77 | - | */ | |
| 78 | - | stopBackgroundTasks(): void { | |
| 79 | - | if (this.heartbeatInterval) { | |
| 80 | - | clearInterval(this.heartbeatInterval); | |
| 81 | - | this.heartbeatInterval = null; | |
| 82 | - | } | |
| 83 | - | if (this.cleanupInterval) { | |
| 84 | - | clearInterval(this.cleanupInterval); | |
| 85 | - | this.cleanupInterval = null; | |
| 86 | - | } | |
| 87 | - | log.info('Background tasks stopped'); | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | /** | |
| 91 | - | * Add a new SSE connection | |
| 92 | - | * @returns Connection ID and whether this is a new user connection | |
| 93 | - | */ | |
| 94 | - | addConnection( | |
| 95 | - | userId: string, | |
| 96 | - | roomId: string, | |
| 97 | - | controller: ReadableStreamDefaultController<Uint8Array> | |
| 98 | - | ): { connectionId: string; isNewUser: boolean } { | |
| 99 | - | const connectionId = crypto.randomUUID(); | |
| 100 | - | const now = Date.now(); | |
| 101 | - | ||
| 102 | - | const connection: SSEConnection = { | |
| 103 | - | connectionId, | |
| 104 | - | userId, | |
| 105 | - | connectedAt: now, | |
| 106 | - | lastActivity: now, | |
| 107 | - | lastHeartbeat: now, | |
| 108 | - | roomId, | |
| 109 | - | controller | |
| 110 | - | }; | |
| 111 | - | ||
| 112 | - | this.connections.set(connectionId, connection); | |
| 113 | - | ||
| 114 | - | // Track user -> connections mapping | |
| 115 | - | let userConns = this.userConnections.get(userId); | |
| 116 | - | const isNewUser = !userConns || userConns.size === 0; | |
| 117 | - | ||
| 118 | - | if (!userConns) { | |
| 119 | - | userConns = new Set(); | |
| 120 | - | this.userConnections.set(userId, userConns); | |
| 121 | - | } | |
| 122 | - | userConns.add(connectionId); | |
| 123 | - | ||
| 124 | - | log.info('Connection added', { | |
| 125 | - | connectionId: connectionId.slice(0, 8), | |
| 126 | - | userId: userId.slice(0, 8), | |
| 127 | - | isNewUser, | |
| 128 | - | userConnectionCount: userConns.size, | |
| 129 | - | totalConnections: this.connections.size | |
| 130 | - | }); | |
| 131 | - | ||
| 132 | - | return { connectionId, isNewUser }; | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | /** | |
| 136 | - | * Remove a connection | |
| 137 | - | * @returns Whether this was the last connection for the user | |
| 138 | - | */ | |
| 139 | - | removeConnection(connectionId: string): { | |
| 140 | - | removed: boolean; | |
| 141 | - | isLastConnection: boolean; | |
| 142 | - | userId: string | null; | |
| 143 | - | } { | |
| 144 | - | const connection = this.connections.get(connectionId); | |
| 145 | - | if (!connection) { | |
| 146 | - | return { removed: false, isLastConnection: false, userId: null }; | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | const { userId } = connection; | |
| 150 | - | this.connections.delete(connectionId); | |
| 151 | - | ||
| 152 | - | // Update user connections map | |
| 153 | - | const userConns = this.userConnections.get(userId); | |
| 154 | - | if (userConns) { | |
| 155 | - | userConns.delete(connectionId); | |
| 156 | - | if (userConns.size === 0) { | |
| 157 | - | this.userConnections.delete(userId); | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | const isLastConnection = !userConns || userConns.size === 0; | |
| 162 | - | ||
| 163 | - | log.info('Connection removed', { | |
| 164 | - | connectionId: connectionId.slice(0, 8), | |
| 165 | - | userId: userId.slice(0, 8), | |
| 166 | - | isLastConnection, | |
| 167 | - | remainingConnections: this.connections.size | |
| 168 | - | }); | |
| 169 | - | ||
| 170 | - | return { removed: true, isLastConnection, userId }; | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | /** | |
| 174 | - | * Get a connection by ID | |
| 175 | - | */ | |
| 176 | - | getConnection(connectionId: string): SSEConnection | undefined { | |
| 177 | - | return this.connections.get(connectionId); | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | /** | |
| 181 | - | * Check if a user has any active connections | |
| 182 | - | */ | |
| 183 | - | hasUserConnection(userId: string): boolean { | |
| 184 | - | const userConns = this.userConnections.get(userId); | |
| 185 | - | return userConns !== undefined && userConns.size > 0; | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | /** | |
| 189 | - | * Get all connection IDs for a user | |
| 190 | - | */ | |
| 191 | - | getUserConnectionIds(userId: string): string[] { | |
| 192 | - | const userConns = this.userConnections.get(userId); | |
| 193 | - | return userConns ? Array.from(userConns) : []; | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | /** | |
| 197 | - | * Update activity timestamp for a connection | |
| 198 | - | */ | |
| 199 | - | updateActivity(connectionId: string): void { | |
| 200 | - | const connection = this.connections.get(connectionId); | |
| 201 | - | if (connection) { | |
| 202 | - | connection.lastActivity = Date.now(); | |
| 203 | - | } | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | /** | |
| 207 | - | * Acknowledge heartbeat for a connection | |
| 208 | - | */ | |
| 209 | - | acknowledgeHeartbeat(connectionId: string): boolean { | |
| 210 | - | const connection = this.connections.get(connectionId); | |
| 211 | - | if (connection) { | |
| 212 | - | connection.lastHeartbeat = Date.now(); | |
| 213 | - | connection.lastActivity = Date.now(); | |
| 214 | - | log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) }); | |
| 215 | - | return true; | |
| 216 | - | } | |
| 217 | - | return false; | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | /** | |
| 221 | - | * Send an event to a specific connection | |
| 222 | - | */ | |
| 223 | - | sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean { | |
| 224 | - | const connection = this.connections.get(connectionId); | |
| 225 | - | if (!connection || !connection.controller) { | |
| 226 | - | return false; | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | try { | |
| 230 | - | const id = eventId ?? crypto.randomUUID(); | |
| 231 | - | const payload = this.formatSSEEvent(event, id); | |
| 232 | - | connection.controller.enqueue(this.encoder.encode(payload)); | |
| 233 | - | ||
| 234 | - | // Queue for catch-up | |
| 235 | - | messageQueue.enqueue(connection.roomId, event.type, event.data); | |
| 236 | - | ||
| 237 | - | return true; | |
| 238 | - | } catch (error) { | |
| 239 | - | log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error }); | |
| 240 | - | return false; | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | /** | |
| 245 | - | * Send an event to a specific user (all their connections) | |
| 246 | - | */ | |
| 247 | - | sendToUser(userId: string, event: SSEEvent): number { | |
| 248 | - | const connectionIds = this.getUserConnectionIds(userId); | |
| 249 | - | let sent = 0; | |
| 250 | - | for (const connId of connectionIds) { | |
| 251 | - | if (this.sendToConnection(connId, event)) { | |
| 252 | - | sent++; | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | return sent; | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | /** | |
| 259 | - | * Broadcast an event to all connections | |
| 260 | - | * Uses the event emitter for distribution | |
| 261 | - | */ | |
| 262 | - | broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void { | |
| 263 | - | const eventId = crypto.randomUUID(); | |
| 264 | - | ||
| 265 | - | // Queue the message first | |
| 266 | - | if (roomId) { | |
| 267 | - | messageQueue.enqueue(roomId, type, data); | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // Emit through the event emitter (listeners on SSE endpoint will pick this up) | |
| 271 | - | sseEmitter.emit(type, data, eventId); | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | /** | |
| 275 | - | * Get messages since a specific event ID (for catch-up) | |
| 276 | - | */ | |
| 277 | - | getMessagesSince(roomId: string, lastEventId: string) { | |
| 278 | - | return messageQueue.getMessagesSince(roomId, lastEventId); | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | /** | |
| 282 | - | * Format an SSE event for transmission | |
| 283 | - | */ | |
| 284 | - | formatSSEEvent(event: SSEEvent, eventId: string): string { | |
| 285 | - | const lines = [ | |
| 286 | - | `id: ${eventId}`, | |
| 287 | - | `event: ${event.type}`, | |
| 288 | - | `data: ${JSON.stringify(event.data)}`, | |
| 289 | - | `retry: ${SSE_CONFIG.RETRY_INTERVAL}`, | |
| 290 | - | '', | |
| 291 | - | '' | |
| 292 | - | ]; | |
| 293 | - | return lines.join('\n'); | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | /** | |
| 297 | - | * Format a keep-alive comment | |
| 298 | - | */ | |
| 299 | - | formatKeepAlive(): string { | |
| 300 | - | return ':\n\n'; | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | /** | |
| 304 | - | * Send heartbeats to all connections | |
| 305 | - | */ | |
| 306 | - | private sendHeartbeats(): void { | |
| 307 | - | const now = Date.now(); | |
| 308 | - | let sent = 0; | |
| 309 | - | ||
| 310 | - | for (const [connectionId, connection] of this.connections) { | |
| 311 | - | if (connection.controller) { | |
| 312 | - | try { | |
| 313 | - | const heartbeatEvent: SSEEvent<'heartbeat'> = { | |
| 314 | - | type: 'heartbeat', | |
| 315 | - | data: { | |
| 316 | - | connectionId, | |
| 317 | - | timestamp: now, | |
| 318 | - | serverTime: new Date(now).toISOString() | |
| 319 | - | } | |
| 320 | - | }; | |
| 321 | - | const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID()); | |
| 322 | - | connection.controller.enqueue(this.encoder.encode(payload)); | |
| 323 | - | sent++; | |
| 324 | - | } catch { | |
| 325 | - | // Connection might be closed, will be cleaned up later | |
| 326 | - | } | |
| 327 | - | } | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | if (sent > 0) { | |
| 331 | - | log.debug('Heartbeats sent', { count: sent }); | |
| 332 | - | } | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | /** | |
| 336 | - | * Clean up stale connections | |
| 337 | - | */ | |
| 338 | - | private cleanupStaleConnections(): void { | |
| 339 | - | const now = Date.now(); | |
| 340 | - | const staleConnections: string[] = []; | |
| 341 | - | ||
| 342 | - | for (const [connectionId, connection] of this.connections) { | |
| 343 | - | const timeSinceHeartbeat = now - connection.lastHeartbeat; | |
| 344 | - | if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) { | |
| 345 | - | staleConnections.push(connectionId); | |
| 346 | - | } | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | if (staleConnections.length > 0) { | |
| 350 | - | log.info('Cleaning up stale connections', { count: staleConnections.length }); | |
| 351 | - | for (const connId of staleConnections) { | |
| 352 | - | this.removeConnection(connId); | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | /** | |
| 358 | - | * Get all active user IDs | |
| 359 | - | */ | |
| 360 | - | getActiveUserIds(): string[] { | |
| 361 | - | return Array.from(this.userConnections.keys()); | |
| 362 | - | } | |
| 363 | - | ||
| 364 | - | /** | |
| 365 | - | * Get connection statistics | |
| 366 | - | */ | |
| 367 | - | getStats(): { | |
| 368 | - | totalConnections: number; | |
| 369 | - | uniqueUsers: number; | |
| 370 | - | queueStats: { rooms: number; totalMessages: number }; | |
| 371 | - | } { | |
| 372 | - | return { | |
| 373 | - | totalConnections: this.connections.size, | |
| 374 | - | uniqueUsers: this.userConnections.size, | |
| 375 | - | queueStats: messageQueue.getStats() | |
| 376 | - | }; | |
| 377 | - | } | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | // Singleton instance | |
| 381 | - | export const sseManager = new SSEManager(); | |
D src/lib/sse/__tests__/MessageQueue.test.ts +0 -156
| @@ -1,156 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Unit tests for MessageQueue | |
| 3 | - | */ | |
| 4 | - | ||
| 5 | - | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; | |
| 6 | - | import { MessageQueue } from '../MessageQueue'; | |
| 7 | - | ||
| 8 | - | describe('MessageQueue', () => { | |
| 9 | - | let queue: MessageQueue; | |
| 10 | - | ||
| 11 | - | beforeEach(() => { | |
| 12 | - | // Create a queue with short TTL for testing | |
| 13 | - | queue = new MessageQueue(1000, 10); // 1 second TTL, max 10 messages | |
| 14 | - | }); | |
| 15 | - | ||
| 16 | - | afterEach(() => { | |
| 17 | - | queue.stopCleanup(); | |
| 18 | - | queue.clear(); | |
| 19 | - | }); | |
| 20 | - | ||
| 21 | - | describe('enqueue', () => { | |
| 22 | - | it('should add a message to the queue and return an ID', () => { | |
| 23 | - | const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' }); | |
| 24 | - | ||
| 25 | - | expect(id).toBeDefined(); | |
| 26 | - | expect(typeof id).toBe('string'); | |
| 27 | - | expect(id.length).toBeGreaterThan(0); | |
| 28 | - | }); | |
| 29 | - | ||
| 30 | - | it('should store messages by room ID', () => { | |
| 31 | - | queue.enqueue('room1', 'chatMessage', { text: 'hello' }); | |
| 32 | - | queue.enqueue('room2', 'chatMessage', { text: 'world' }); | |
| 33 | - | ||
| 34 | - | const room1Messages = queue.getMessages('room1'); | |
| 35 | - | const room2Messages = queue.getMessages('room2'); | |
| 36 | - | ||
| 37 | - | expect(room1Messages).toHaveLength(1); | |
| 38 | - | expect(room2Messages).toHaveLength(1); | |
| 39 | - | expect(room1Messages[0].data).toEqual({ text: 'hello' }); | |
| 40 | - | expect(room2Messages[0].data).toEqual({ text: 'world' }); | |
| 41 | - | }); | |
| 42 | - | ||
| 43 | - | it('should enforce max queue size', () => { | |
| 44 | - | // Add more than max (10) messages | |
| 45 | - | for (let i = 0; i < 15; i++) { | |
| 46 | - | queue.enqueue('room1', 'chatMessage', { index: i }); | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | const messages = queue.getMessages('room1'); | |
| 50 | - | expect(messages).toHaveLength(10); | |
| 51 | - | ||
| 52 | - | // Should keep the newest messages | |
| 53 | - | expect(messages[0].data).toEqual({ index: 5 }); | |
| 54 | - | expect(messages[9].data).toEqual({ index: 14 }); | |
| 55 | - | }); | |
| 56 | - | }); | |
| 57 | - | ||
| 58 | - | describe('getMessagesSince', () => { | |
| 59 | - | it('should return messages after a given ID', () => { | |
| 60 | - | const id1 = queue.enqueue('room1', 'chatMessage', { index: 1 }); | |
| 61 | - | const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 }); | |
| 62 | - | queue.enqueue('room1', 'chatMessage', { index: 3 }); | |
| 63 | - | ||
| 64 | - | const messagesSinceId1 = queue.getMessagesSince('room1', id1); | |
| 65 | - | expect(messagesSinceId1).toHaveLength(2); | |
| 66 | - | expect(messagesSinceId1[0].data).toEqual({ index: 2 }); | |
| 67 | - | expect(messagesSinceId1[1].data).toEqual({ index: 3 }); | |
| 68 | - | ||
| 69 | - | const messagesSinceId2 = queue.getMessagesSince('room1', id2); | |
| 70 | - | expect(messagesSinceId2).toHaveLength(1); | |
| 71 | - | expect(messagesSinceId2[0].data).toEqual({ index: 3 }); | |
| 72 | - | }); | |
| 73 | - | ||
| 74 | - | it('should return recent messages if ID not found', () => { | |
| 75 | - | for (let i = 0; i < 5; i++) { | |
| 76 | - | queue.enqueue('room1', 'chatMessage', { index: i }); | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | const messages = queue.getMessagesSince('room1', 'unknown-id'); | |
| 80 | - | expect(messages).toHaveLength(5); | |
| 81 | - | }); | |
| 82 | - | ||
| 83 | - | it('should return empty array for non-existent room', () => { | |
| 84 | - | const messages = queue.getMessagesSince('nonexistent', 'some-id'); | |
| 85 | - | expect(messages).toEqual([]); | |
| 86 | - | }); | |
| 87 | - | }); | |
| 88 | - | ||
| 89 | - | describe('getLatestEventId', () => { | |
| 90 | - | it('should return the ID of the last message', () => { | |
| 91 | - | queue.enqueue('room1', 'chatMessage', { index: 1 }); | |
| 92 | - | const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 }); | |
| 93 | - | ||
| 94 | - | expect(queue.getLatestEventId('room1')).toBe(id2); | |
| 95 | - | }); | |
| 96 | - | ||
| 97 | - | it('should return null for empty room', () => { | |
| 98 | - | expect(queue.getLatestEventId('nonexistent')).toBeNull(); | |
| 99 | - | }); | |
| 100 | - | }); | |
| 101 | - | ||
| 102 | - | describe('hasEventId', () => { | |
| 103 | - | it('should return true if event ID exists', () => { | |
| 104 | - | const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' }); | |
| 105 | - | expect(queue.hasEventId('room1', id)).toBe(true); | |
| 106 | - | }); | |
| 107 | - | ||
| 108 | - | it('should return false if event ID does not exist', () => { | |
| 109 | - | expect(queue.hasEventId('room1', 'unknown')).toBe(false); | |
| 110 | - | }); | |
| 111 | - | }); | |
| 112 | - | ||
| 113 | - | describe('cleanup', () => { | |
| 114 | - | it('should remove expired messages', async () => { | |
| 115 | - | vi.useFakeTimers(); | |
| 116 | - | ||
| 117 | - | queue.enqueue('room1', 'chatMessage', { text: 'old' }); | |
| 118 | - | ||
| 119 | - | // Fast-forward past TTL | |
| 120 | - | vi.advanceTimersByTime(2000); | |
| 121 | - | ||
| 122 | - | const removed = queue.cleanup(); | |
| 123 | - | expect(removed).toBe(1); | |
| 124 | - | expect(queue.getMessages('room1')).toHaveLength(0); | |
| 125 | - | ||
| 126 | - | vi.useRealTimers(); | |
| 127 | - | }); | |
| 128 | - | ||
| 129 | - | it('should keep non-expired messages', async () => { | |
| 130 | - | vi.useFakeTimers(); | |
| 131 | - | ||
| 132 | - | queue.enqueue('room1', 'chatMessage', { text: 'message' }); | |
| 133 | - | ||
| 134 | - | // Fast-forward but not past TTL | |
| 135 | - | vi.advanceTimersByTime(500); | |
| 136 | - | ||
| 137 | - | const removed = queue.cleanup(); | |
| 138 | - | expect(removed).toBe(0); | |
| 139 | - | expect(queue.getMessages('room1')).toHaveLength(1); | |
| 140 | - | ||
| 141 | - | vi.useRealTimers(); | |
| 142 | - | }); | |
| 143 | - | }); | |
| 144 | - | ||
| 145 | - | describe('getStats', () => { | |
| 146 | - | it('should return correct statistics', () => { | |
| 147 | - | queue.enqueue('room1', 'chatMessage', { text: '1' }); | |
| 148 | - | queue.enqueue('room1', 'chatMessage', { text: '2' }); | |
| 149 | - | queue.enqueue('room2', 'chatMessage', { text: '3' }); | |
| 150 | - | ||
| 151 | - | const stats = queue.getStats(); | |
| 152 | - | expect(stats.rooms).toBe(2); | |
| 153 | - | expect(stats.totalMessages).toBe(3); | |
| 154 | - | }); | |
| 155 | - | }); | |
| 156 | - | }); | |
D src/lib/sse/__tests__/SSEEventEmitter.test.ts +0 -187
| @@ -1,187 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Unit tests for SSEEventEmitter | |
| 3 | - | */ | |
| 4 | - | ||
| 5 | - | import { describe, it, expect, beforeEach, vi } from 'vitest'; | |
| 6 | - | ||
| 7 | - | // We need to import the class, not the singleton for testing | |
| 8 | - | // First, let's create a test-friendly version | |
| 9 | - | ||
| 10 | - | describe('SSEEventEmitter', () => { | |
| 11 | - | // Import the module fresh for each test | |
| 12 | - | let SSEEventEmitter: typeof import('../SSEEventEmitter'); | |
| 13 | - | ||
| 14 | - | beforeEach(async () => { | |
| 15 | - | // Clear module cache and re-import | |
| 16 | - | vi.resetModules(); | |
| 17 | - | SSEEventEmitter = await import('../SSEEventEmitter'); | |
| 18 | - | }); | |
| 19 | - | ||
| 20 | - | describe('emit', () => { | |
| 21 | - | it('should broadcast events to all listeners on the sse channel', () => { | |
| 22 | - | const { sseEmitter } = SSEEventEmitter; | |
| 23 | - | const listener = vi.fn(); | |
| 24 | - | ||
| 25 | - | sseEmitter.addListener('sse', listener); | |
| 26 | - | ||
| 27 | - | sseEmitter.emit('chatMessage', { | |
| 28 | - | id: '123', | |
| 29 | - | chatRoomId: 'room1', | |
| 30 | - | senderId: 'user1', | |
| 31 | - | content: 'Hello', | |
| 32 | - | type: 'chat', | |
| 33 | - | timestamp: Date.now() | |
| 34 | - | }); | |
| 35 | - | ||
| 36 | - | expect(listener).toHaveBeenCalledTimes(1); | |
| 37 | - | expect(listener).toHaveBeenCalledWith( | |
| 38 | - | expect.objectContaining({ | |
| 39 | - | type: 'chatMessage', | |
| 40 | - | data: expect.objectContaining({ | |
| 41 | - | id: '123', | |
| 42 | - | content: 'Hello' | |
| 43 | - | }) | |
| 44 | - | }) | |
| 45 | - | ); | |
| 46 | - | }); | |
| 47 | - | ||
| 48 | - | it('should include event ID when provided', () => { | |
| 49 | - | const { sseEmitter } = SSEEventEmitter; | |
| 50 | - | const listener = vi.fn(); | |
| 51 | - | ||
| 52 | - | sseEmitter.addListener('sse', listener); | |
| 53 | - | ||
| 54 | - | sseEmitter.emit( | |
| 55 | - | 'chatMessage', | |
| 56 | - | { | |
| 57 | - | id: '123', | |
| 58 | - | chatRoomId: 'room1', | |
| 59 | - | senderId: 'user1', | |
| 60 | - | content: 'Hello', | |
| 61 | - | type: 'chat', | |
| 62 | - | timestamp: Date.now() | |
| 63 | - | }, | |
| 64 | - | 'event-id-456' | |
| 65 | - | ); | |
| 66 | - | ||
| 67 | - | expect(listener).toHaveBeenCalledWith( | |
| 68 | - | expect.objectContaining({ | |
| 69 | - | id: 'event-id-456' | |
| 70 | - | }) | |
| 71 | - | ); | |
| 72 | - | }); | |
| 73 | - | }); | |
| 74 | - | ||
| 75 | - | describe('addListener', () => { | |
| 76 | - | it('should return a cleanup function', () => { | |
| 77 | - | const { sseEmitter } = SSEEventEmitter; | |
| 78 | - | const listener = vi.fn(); | |
| 79 | - | ||
| 80 | - | const cleanup = sseEmitter.addListener('sse', listener); | |
| 81 | - | ||
| 82 | - | expect(typeof cleanup).toBe('function'); | |
| 83 | - | expect(sseEmitter.getListenerCount('sse')).toBe(1); | |
| 84 | - | ||
| 85 | - | cleanup(); | |
| 86 | - | expect(sseEmitter.getListenerCount('sse')).toBe(0); | |
| 87 | - | }); | |
| 88 | - | ||
| 89 | - | it('should support multiple listeners', () => { | |
| 90 | - | const { sseEmitter } = SSEEventEmitter; | |
| 91 | - | const listener1 = vi.fn(); | |
| 92 | - | const listener2 = vi.fn(); | |
| 93 | - | ||
| 94 | - | sseEmitter.addListener('sse', listener1); | |
| 95 | - | sseEmitter.addListener('sse', listener2); | |
| 96 | - | ||
| 97 | - | sseEmitter.emit('buddyListUpdate', []); | |
| 98 | - | ||
| 99 | - | expect(listener1).toHaveBeenCalledTimes(1); | |
| 100 | - | expect(listener2).toHaveBeenCalledTimes(1); | |
| 101 | - | }); | |
| 102 | - | }); | |
| 103 | - | ||
| 104 | - | describe('removeListener', () => { | |
| 105 | - | it('should remove a specific listener', () => { | |
| 106 | - | const { sseEmitter } = SSEEventEmitter; | |
| 107 | - | const listener = vi.fn(); | |
| 108 | - | ||
| 109 | - | sseEmitter.addListener('sse', listener); | |
| 110 | - | expect(sseEmitter.getListenerCount('sse')).toBe(1); | |
| 111 | - | ||
| 112 | - | const removed = sseEmitter.removeListener('sse', listener); | |
| 113 | - | expect(removed).toBe(true); | |
| 114 | - | expect(sseEmitter.getListenerCount('sse')).toBe(0); | |
| 115 | - | }); | |
| 116 | - | ||
| 117 | - | it('should return false for non-existent listener', () => { | |
| 118 | - | const { sseEmitter } = SSEEventEmitter; | |
| 119 | - | const listener = vi.fn(); | |
| 120 | - | ||
| 121 | - | const removed = sseEmitter.removeListener('sse', listener); | |
| 122 | - | expect(removed).toBe(false); | |
| 123 | - | }); | |
| 124 | - | }); | |
| 125 | - | ||
| 126 | - | describe('removeAllListeners', () => { | |
| 127 | - | it('should remove all listeners for a channel', () => { | |
| 128 | - | const { sseEmitter } = SSEEventEmitter; | |
| 129 | - | const listener1 = vi.fn(); | |
| 130 | - | const listener2 = vi.fn(); | |
| 131 | - | ||
| 132 | - | sseEmitter.addListener('sse', listener1); | |
| 133 | - | sseEmitter.addListener('sse', listener2); | |
| 134 | - | expect(sseEmitter.getListenerCount('sse')).toBe(2); | |
| 135 | - | ||
| 136 | - | sseEmitter.removeAllListeners('sse'); | |
| 137 | - | expect(sseEmitter.getListenerCount('sse')).toBe(0); | |
| 138 | - | }); | |
| 139 | - | ||
| 140 | - | it('should remove all listeners when no channel specified', () => { | |
| 141 | - | const { sseEmitter } = SSEEventEmitter; | |
| 142 | - | const listener = vi.fn(); | |
| 143 | - | ||
| 144 | - | sseEmitter.addListener('sse', listener); | |
| 145 | - | sseEmitter.addListener('other', listener); | |
| 146 | - | ||
| 147 | - | sseEmitter.removeAllListeners(); | |
| 148 | - | expect(sseEmitter.getListenerCount()).toBe(0); | |
| 149 | - | }); | |
| 150 | - | }); | |
| 151 | - | ||
| 152 | - | describe('hasListeners', () => { | |
| 153 | - | it('should return true when channel has listeners', () => { | |
| 154 | - | const { sseEmitter } = SSEEventEmitter; | |
| 155 | - | const listener = vi.fn(); | |
| 156 | - | ||
| 157 | - | sseEmitter.addListener('sse', listener); | |
| 158 | - | expect(sseEmitter.hasListeners('sse')).toBe(true); | |
| 159 | - | }); | |
| 160 | - | ||
| 161 | - | it('should return false when channel has no listeners', () => { | |
| 162 | - | const { sseEmitter } = SSEEventEmitter; | |
| 163 | - | expect(sseEmitter.hasListeners('sse')).toBe(false); | |
| 164 | - | }); | |
| 165 | - | }); | |
| 166 | - | ||
| 167 | - | describe('error handling', () => { | |
| 168 | - | it('should continue broadcasting even if a listener throws', () => { | |
| 169 | - | const { sseEmitter } = SSEEventEmitter; | |
| 170 | - | const badListener = vi.fn(() => { | |
| 171 | - | throw new Error('Listener error'); | |
| 172 | - | }); | |
| 173 | - | const goodListener = vi.fn(); | |
| 174 | - | ||
| 175 | - | sseEmitter.addListener('sse', badListener); | |
| 176 | - | sseEmitter.addListener('sse', goodListener); | |
| 177 | - | ||
| 178 | - | // Should not throw | |
| 179 | - | expect(() => { | |
| 180 | - | sseEmitter.emit('buddyListUpdate', []); | |
| 181 | - | }).not.toThrow(); | |
| 182 | - | ||
| 183 | - | // Good listener should still be called | |
| 184 | - | expect(goodListener).toHaveBeenCalledTimes(1); | |
| 185 | - | }); | |
| 186 | - | }); | |
| 187 | - | }); | |
D src/lib/sse/config.ts +0 -53
| @@ -1,53 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Centralized SSE Configuration Constants | |
| 3 | - | * | |
| 4 | - | * All timing constants for SSE connection management are defined here | |
| 5 | - | * to ensure consistency between server and client implementations. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | export const SSE_CONFIG = { | |
| 9 | - | // Server-side timing | |
| 10 | - | /** Heartbeat interval - server sends ping every 30 seconds */ | |
| 11 | - | HEARTBEAT_INTERVAL: 30_000, | |
| 12 | - | ||
| 13 | - | /** Connection timeout - mark user offline after 5 minutes of inactivity */ | |
| 14 | - | CONNECTION_TIMEOUT: 5 * 60_000, | |
| 15 | - | ||
| 16 | - | /** Stale connection check interval - cleanup every 60 seconds */ | |
| 17 | - | STALE_CHECK_INTERVAL: 60_000, | |
| 18 | - | ||
| 19 | - | /** Message queue TTL - keep messages for 5 minutes for catch-up */ | |
| 20 | - | MESSAGE_QUEUE_TTL: 5 * 60_000, | |
| 21 | - | ||
| 22 | - | /** Maximum messages to queue per room */ | |
| 23 | - | MESSAGE_QUEUE_MAX_SIZE: 1000, | |
| 24 | - | ||
| 25 | - | /** Buddy list broadcast interval */ | |
| 26 | - | BUDDY_LIST_BROADCAST_INTERVAL: 10_000, | |
| 27 | - | ||
| 28 | - | // Client-side timing | |
| 29 | - | /** Initial reconnect delay - 1 second */ | |
| 30 | - | INITIAL_RECONNECT_DELAY: 1_000, | |
| 31 | - | ||
| 32 | - | /** Maximum reconnect delay - 60 seconds cap */ | |
| 33 | - | MAX_RECONNECT_DELAY: 60_000, | |
| 34 | - | ||
| 35 | - | /** Heartbeat timeout - 45 seconds (1.5x server interval) */ | |
| 36 | - | HEARTBEAT_TIMEOUT: 45_000, | |
| 37 | - | ||
| 38 | - | // Shared timing | |
| 39 | - | /** Idle timeout - mark as idle after 2 minutes */ | |
| 40 | - | IDLE_TIMEOUT: 2 * 60_000, | |
| 41 | - | ||
| 42 | - | /** Status update interval */ | |
| 43 | - | STATUS_UPDATE_INTERVAL: 30_000, | |
| 44 | - | ||
| 45 | - | /** EventSource retry hint (ms) - sent to client */ | |
| 46 | - | RETRY_INTERVAL: 3_000 | |
| 47 | - | } as const; | |
| 48 | - | ||
| 49 | - | /** SSE Event Types */ | |
| 50 | - | export type SSEEventType = 'chatMessage' | 'userStatusUpdate' | 'buddyListUpdate' | 'heartbeat'; | |
| 51 | - | ||
| 52 | - | /** Connection status */ | |
| 53 | - | export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected'; | |
M src/lib/states/chat.svelte.ts +25 -689
| @@ -1,730 +1,66 @@ | |||
| 1 | 1 | /** | |
| 2 | 2 | * Chat State Management | |
| 3 | 3 | * | |
| 4 | - | * Central state manager for chat functionality using Svelte 5 runes. | |
| 5 | - | * Handles SSE connections, messages, and user state. | |
| 4 | + | * Minimal state manager for user session. | |
| 5 | + | * Real-time data (messages, users) comes from Convex subscriptions in components. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | - | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 8 | + | import type { User, SafeUser } from '../types/chat'; | |
| 9 | 9 | import { createSafeUser } from '../types/chat'; | |
| 10 | - | import type { | |
| 11 | - | SendMessageRequest, | |
| 12 | - | SendMessageResponse, | |
| 13 | - | GetMessagesResponse | |
| 14 | - | } from '../types/payloads'; | |
| 15 | - | import type { TextStyle } from '../types/text-formatting'; | |
| 16 | - | import { invalidate } from '$app/navigation'; | |
| 17 | - | import { env } from '$env/dynamic/public'; | |
| 18 | - | import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte'; | |
| 19 | - | import type { SSEEventMap } from '../sse/SSEEventEmitter'; | |
| 20 | - | import type { ConnectionStatus } from '../sse/config'; | |
| 21 | - | ||
| 22 | - | // Use the public environment variable with a fallback | |
| 23 | - | const DEFAULT_CHAT_ROOM_ID = | |
| 24 | - | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 25 | 10 | ||
| 26 | 11 | class ChatState { | |
| 27 | - | // User and message state | |
| 28 | - | private users = $state<SafeUser[]>([]); | |
| 29 | - | private messages = $state<Message[]>([]); | |
| 30 | - | private currentUser = $state<SafeUser | null>(null); | |
| 31 | - | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 32 | - | private userCache = $state<Record<string, SafeUser>>({}); | |
| 33 | - | private hasMoreMessages = $state(false); | |
| 34 | - | ||
| 35 | - | // SSE Client | |
| 36 | - | private sseClient: SSEClient | null = null; | |
| 37 | - | ||
| 38 | - | // Connection state | |
| 39 | - | private _connectionStatus = $state<ConnectionStatus>('disconnected'); | |
| 40 | - | private _sseError = $state<string | null>(null); | |
| 41 | - | ||
| 42 | - | // Internal state flags | |
| 43 | - | private isInitializing = $state(false); | |
| 44 | - | private isSettingUser = $state(false); | |
| 45 | - | ||
| 46 | - | // Memoization for enriched messages | |
| 47 | - | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 48 | - | private _lastEnrichmentKey: string = ''; | |
| 49 | - | ||
| 50 | - | // Buddy list tracking | |
| 51 | - | private lastBuddyListHash = ''; | |
| 12 | + | // Current user state (from session) | |
| 13 | + | private _currentUser = $state<SafeUser | null>(null); | |
| 14 | + | private _currentRoomId = $state<string | null>(null); | |
| 52 | 15 | ||
| 53 | - | // Public polling for unauthenticated users | |
| 54 | - | private publicPollingInterval: ReturnType<typeof setInterval> | null = null; | |
| 16 | + | // Connection state for UI feedback | |
| 17 | + | private _connectionStatus = $state<'connected' | 'disconnected' | 'reconnecting'>('disconnected'); | |
| 55 | 18 | ||
| 56 | - | // Derived state | |
| 19 | + | // Derived public state | |
| 57 | 20 | public connectionStatus = $derived(this._connectionStatus); | |
| 58 | - | public sseError = $derived(this._sseError); | |
| 59 | 21 | public isConnected = $derived(this._connectionStatus === 'connected'); | |
| 60 | - | public isReconnecting = $derived(this._connectionStatus === 'reconnecting'); | |
| 61 | - | ||
| 62 | - | /** | |
| 63 | - | * Reinitialize the chat state (e.g., after login) | |
| 64 | - | */ | |
| 65 | - | public async reinitialize() { | |
| 66 | - | if (this.isInitializing) { | |
| 67 | - | console.debug('Reinitialize already in progress, skipping.'); | |
| 68 | - | return; | |
| 69 | - | } | |
| 70 | - | this.isInitializing = true; | |
| 71 | - | console.debug('Reinitializing chat state...'); | |
| 72 | - | ||
| 73 | - | // Disconnect existing SSE client | |
| 74 | - | if (this.sseClient) { | |
| 75 | - | this.sseClient.disconnect(); | |
| 76 | - | this.sseClient = null; | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | this.messages = []; | |
| 80 | - | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 81 | - | ||
| 82 | - | if (this.currentUser) { | |
| 83 | - | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 84 | - | await new Promise((resolve) => setTimeout(resolve, 1000)); | |
| 85 | - | ||
| 86 | - | // Set up SSE connection | |
| 87 | - | this.setupSSE(); | |
| 88 | - | ||
| 89 | - | // Initialize messages and room users | |
| 90 | - | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | this.isInitializing = false; | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | /** | |
| 97 | - | * Get SSE error information | |
| 98 | - | */ | |
| 99 | - | getSSEError() { | |
| 100 | - | return { | |
| 101 | - | error: this._sseError, | |
| 102 | - | status: this._connectionStatus, | |
| 103 | - | retryAfter: this.sseClient?.getReconnectDelay() ?? null | |
| 104 | - | }; | |
| 105 | - | } | |
| 106 | 22 | ||
| 107 | 23 | /** | |
| 108 | 24 | * Get the current user | |
| 109 | 25 | */ | |
| 110 | 26 | getCurrentUser() { | |
| 111 | - | return this.currentUser; | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | /** | |
| 115 | - | * Set the current user | |
| 116 | - | */ | |
| 117 | - | async setCurrentUser(user: User | SafeUser | null) { | |
| 118 | - | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 119 | - | ||
| 120 | - | if (this.isSettingUser) { | |
| 121 | - | console.debug('Already setting user, skipping'); | |
| 122 | - | return; | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | this.isSettingUser = true; | |
| 126 | - | ||
| 127 | - | try { | |
| 128 | - | if (!user && !this.currentUser) { | |
| 129 | - | console.debug('No user to set and no current user, skipping'); | |
| 130 | - | return; | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 134 | - | ||
| 135 | - | // Check if same user with active connection | |
| 136 | - | if (safeUser?.id === this.currentUser?.id) { | |
| 137 | - | if (!this.sseClient || !this.sseClient.isConnected) { | |
| 138 | - | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 139 | - | } else { | |
| 140 | - | console.debug('Same user being set with active connection, skipping.'); | |
| 141 | - | return; | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | this.messages = []; | |
| 146 | - | this.currentUser = safeUser; | |
| 147 | - | ||
| 148 | - | if (safeUser) { | |
| 149 | - | this.userCache[safeUser.id] = safeUser; | |
| 150 | - | await this.reinitialize(); | |
| 151 | - | } else { | |
| 152 | - | // Cleanup when user is removed | |
| 153 | - | if (this.sseClient) { | |
| 154 | - | this.sseClient.disconnect(); | |
| 155 | - | this.sseClient = null; | |
| 156 | - | } | |
| 157 | - | this.messages = []; | |
| 158 | - | this.users = []; | |
| 159 | - | this.userCache = {}; | |
| 160 | - | this._connectionStatus = 'disconnected'; | |
| 161 | - | } | |
| 162 | - | } finally { | |
| 163 | - | this.isSettingUser = false; | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | /** | |
| 168 | - | * Initialize room users | |
| 169 | - | */ | |
| 170 | - | private async initializeRoomUsers() { | |
| 171 | - | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 172 | - | try { | |
| 173 | - | const isPublic = !this.currentUser; | |
| 174 | - | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 175 | - | console.debug('Fetching room users from:', url); | |
| 176 | - | ||
| 177 | - | const response = await fetch(url, { credentials: 'include' }); | |
| 178 | - | ||
| 179 | - | if (!response.ok) { | |
| 180 | - | throw new Error('Failed to fetch room users'); | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | const data = await response.json(); | |
| 184 | - | if (!data.success) { | |
| 185 | - | throw new Error(data.error); | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | console.debug('Fetched room buddy list:', data.buddyList); | |
| 189 | - | ||
| 190 | - | // Merge fetched buddy list with existing cached users | |
| 191 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging | |
| 192 | - | const mergedUsers = new Map<string, SafeUser>(); | |
| 193 | - | Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user)); | |
| 194 | - | data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user)); | |
| 195 | - | ||
| 196 | - | this.users = Array.from(mergedUsers.values()); | |
| 197 | - | this.users.forEach((user) => { | |
| 198 | - | this.userCache[user.id] = user; | |
| 199 | - | }); | |
| 200 | - | } catch (error) { | |
| 201 | - | console.debug('Error fetching room users:', error); | |
| 202 | - | this.users = []; | |
| 203 | - | } | |
| 27 | + | return this._currentUser; | |
| 204 | 28 | } | |
| 205 | 29 | ||
| 206 | 30 | /** | |
| 207 | - | * Get online users sorted by status | |
| 31 | + | * Set the current user (called from layout when session changes) | |
| 208 | 32 | */ | |
| 209 | - | getOnlineUsers() { | |
| 210 | - | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 211 | - | const statusOrder: Record<SafeUser['status'], number> = { | |
| 212 | - | online: 0, | |
| 213 | - | away: 1, | |
| 214 | - | busy: 2, | |
| 215 | - | offline: 3 | |
| 216 | - | }; | |
| 217 | - | return statusOrder[a.status] - statusOrder[b.status]; | |
| 218 | - | }); | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | /** | |
| 222 | - | * Get user by ID | |
| 223 | - | */ | |
| 224 | - | getUserById(userId: string) { | |
| 225 | - | if (this.userCache[userId]) { | |
| 226 | - | return this.userCache[userId]; | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | const fallback: SafeUser = { | |
| 230 | - | id: userId, | |
| 231 | - | nickname: 'Unknown User', | |
| 232 | - | status: 'offline', | |
| 233 | - | lastSeen: null, | |
| 234 | - | avatarUrl: null | |
| 235 | - | }; | |
| 236 | - | this.userCache[userId] = fallback; | |
| 237 | - | console.debug('getUserById: returning fallback for missing user', { userId, fallback }); | |
| 238 | - | return fallback; | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | /** | |
| 242 | - | * Update user status | |
| 243 | - | */ | |
| 244 | - | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | |
| 245 | - | console.debug('Updating user status with:', { | |
| 246 | - | userId, | |
| 247 | - | status, | |
| 248 | - | lastSeen, | |
| 249 | - | currentCache: this.userCache[userId], | |
| 250 | - | currentUsers: this.users.find((u) => u.id === userId) | |
| 251 | - | }); | |
| 252 | - | ||
| 253 | - | const existingUser = this.userCache[userId]; | |
| 254 | - | if ( | |
| 255 | - | existingUser && | |
| 256 | - | existingUser.status === status && | |
| 257 | - | lastSeen && | |
| 258 | - | Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000 | |
| 259 | - | ) { | |
| 260 | - | console.debug('Skipping redundant status update for user:', userId); | |
| 261 | - | return; | |
| 262 | - | } | |
| 263 | - | ||
| 264 | - | let user: SafeUser | undefined = this.userCache[userId]; | |
| 265 | - | ||
| 33 | + | setCurrentUser(user: User | SafeUser | null) { | |
| 266 | 34 | if (!user) { | |
| 267 | - | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 268 | - | if (foundUser) { | |
| 269 | - | user = foundUser; | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | const now = Date.now(); | |
| 274 | - | if (user) { | |
| 275 | - | const updatedUser: SafeUser = { | |
| 276 | - | ...user, | |
| 277 | - | status, | |
| 278 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 279 | - | }; | |
| 280 | - | ||
| 281 | - | this.userCache[userId] = updatedUser; | |
| 282 | - | ||
| 283 | - | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 284 | - | if (index !== -1) { | |
| 285 | - | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | |
| 286 | - | } else { | |
| 287 | - | this.users = [...this.users, updatedUser]; | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | console.debug('User status updated:', { | |
| 291 | - | userId, | |
| 292 | - | newStatus: status, | |
| 293 | - | newLastSeen: new Date(updatedUser.lastSeen || now).toISOString() | |
| 294 | - | }); | |
| 295 | - | } else { | |
| 296 | - | console.debug('User not found in cache or list, fetching from API:', userId); | |
| 297 | - | fetch(`/api/users/${userId}`, { credentials: 'include' }) | |
| 298 | - | .then((response) => response.json()) | |
| 299 | - | .then((data) => { | |
| 300 | - | if (data.success && data.user) { | |
| 301 | - | const newUser: SafeUser = { | |
| 302 | - | ...data.user, | |
| 303 | - | status, | |
| 304 | - | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| 305 | - | }; | |
| 306 | - | this.userCache[userId] = newUser; | |
| 307 | - | this.users = [...this.users, newUser]; | |
| 308 | - | ||
| 309 | - | console.debug('User fetched and status updated:', { | |
| 310 | - | userId, | |
| 311 | - | status: newUser.status | |
| 312 | - | }); | |
| 313 | - | } | |
| 314 | - | }) | |
| 315 | - | .catch((error) => console.error('Error fetching user data:', error)); | |
| 316 | - | } | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | /** | |
| 320 | - | * Get enriched messages with user data | |
| 321 | - | */ | |
| 322 | - | getMessages() { | |
| 323 | - | const key = | |
| 324 | - | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| 325 | - | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { | |
| 326 | - | return this._memoizedEnrichedMessages; | |
| 327 | - | } | |
| 328 | - | this._lastEnrichmentKey = key; | |
| 329 | - | console.debug('Enriching messages with user data', { | |
| 330 | - | messagesCount: this.messages.length, | |
| 331 | - | userCacheSize: Object.keys(this.userCache).length | |
| 332 | - | }); | |
| 333 | - | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | |
| 334 | - | return this._memoizedEnrichedMessages; | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | /** | |
| 338 | - | * Prepend messages (for loading older messages) | |
| 339 | - | */ | |
| 340 | - | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 341 | - | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 342 | - | ||
| 343 | - | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | |
| 344 | - | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 345 | - | ||
| 346 | - | if (missingUserIds.length > 0) { | |
| 347 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 348 | - | Promise.all( | |
| 349 | - | missingUserIds.map(async (userId) => { | |
| 350 | - | try { | |
| 351 | - | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 352 | - | credentials: 'include' | |
| 353 | - | }); | |
| 354 | - | if (userResponse.ok) { | |
| 355 | - | const userData = await userResponse.json(); | |
| 356 | - | if (userData.success && userData.user) { | |
| 357 | - | this.userCache[userId] = userData.user; | |
| 358 | - | if (!this.users.some((u) => u.id === userId)) { | |
| 359 | - | this.users = [...this.users, userData.user]; | |
| 360 | - | } | |
| 361 | - | } | |
| 362 | - | } | |
| 363 | - | } catch (error) { | |
| 364 | - | console.debug('Error fetching user data:', { userId, error }); | |
| 365 | - | } | |
| 366 | - | }) | |
| 367 | - | ); | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 371 | - | const uniqueNewMessages = sortedNewMessages.filter( | |
| 372 | - | (msg) => !this.messages.some((m) => m.id === msg.id) | |
| 373 | - | ); | |
| 374 | - | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 375 | - | ||
| 376 | - | if (hasMore !== undefined) { | |
| 377 | - | this.hasMoreMessages = hasMore; | |
| 378 | - | } | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | /** | |
| 382 | - | * Update user cache | |
| 383 | - | */ | |
| 384 | - | updateUserCache(users: (User | SafeUser)[]) { | |
| 385 | - | console.debug( | |
| 386 | - | 'Updating user cache with users:', | |
| 387 | - | users.map((u) => ({ ...u, password: '[REDACTED]' })) | |
| 388 | - | ); | |
| 389 | - | users.forEach((user) => { | |
| 390 | - | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 391 | - | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 392 | - | this.userCache[user.id] = safeUser; | |
| 393 | - | const existingIndex = this.users.findIndex((u) => u.id === user.id); | |
| 394 | - | if (existingIndex === -1) { | |
| 395 | - | this.users = [...this.users, safeUser]; | |
| 396 | - | } else if (safeUser.status !== this.users[existingIndex].status) { | |
| 397 | - | this.users = [ | |
| 398 | - | ...this.users.slice(0, existingIndex), | |
| 399 | - | safeUser, | |
| 400 | - | ...this.users.slice(existingIndex + 1) | |
| 401 | - | ]; | |
| 402 | - | } | |
| 403 | - | } | |
| 404 | - | }); | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | /** | |
| 408 | - | * Update online users (from buddy list updates) | |
| 409 | - | */ | |
| 410 | - | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 411 | - | const now = Date.now(); | |
| 412 | - | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | |
| 413 | - | const newHash = JSON.stringify(safeUsers); | |
| 414 | - | ||
| 415 | - | if (newHash !== this.lastBuddyListHash) { | |
| 416 | - | const statusCounts = safeUsers.reduce( | |
| 417 | - | (acc, user) => { | |
| 418 | - | acc[user.status] = (acc[user.status] || 0) + 1; | |
| 419 | - | return acc; | |
| 420 | - | }, | |
| 421 | - | {} as Record<string, number> | |
| 422 | - | ); | |
| 423 | - | ||
| 424 | - | console.debug('Updating online users (data changed):', { | |
| 425 | - | total: safeUsers.length, | |
| 426 | - | byStatus: statusCounts, | |
| 427 | - | timestamp: new Date(now).toISOString() | |
| 428 | - | }); | |
| 429 | - | ||
| 430 | - | this.users = safeUsers; | |
| 431 | - | safeUsers.forEach((user) => { | |
| 432 | - | this.userCache[user.id] = user; | |
| 433 | - | }); | |
| 434 | - | this.lastBuddyListHash = newHash; | |
| 435 | - | } | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | /** | |
| 439 | - | * Update messages (for public/unauthenticated access) | |
| 440 | - | */ | |
| 441 | - | updateMessages(messages: Message[]) { | |
| 442 | - | const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values()); | |
| 443 | - | this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp); | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | /** | |
| 447 | - | * Initialize messages from the server | |
| 448 | - | */ | |
| 449 | - | async initializeMessages() { | |
| 450 | - | try { | |
| 451 | - | // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building | |
| 452 | - | const params = new URLSearchParams(); | |
| 453 | - | if (!this.currentUser) { | |
| 454 | - | params.append('public', 'true'); | |
| 455 | - | } | |
| 456 | - | params.append('roomId', this.currentRoomId); | |
| 457 | - | ||
| 458 | - | const response = await fetch(`/api/chat/messages?${params}`, { | |
| 459 | - | credentials: 'include' | |
| 460 | - | }); | |
| 461 | - | if (!response.ok) throw new Error('Failed to fetch messages'); | |
| 462 | - | ||
| 463 | - | const data = (await response.json()) as GetMessagesResponse; | |
| 464 | - | if (!data.success) { | |
| 465 | - | throw new Error(data.error); | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | |
| 469 | - | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 470 | - | ||
| 471 | - | if (missingUserIds.length > 0) { | |
| 472 | - | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 473 | - | await Promise.all( | |
| 474 | - | missingUserIds.map(async (userId) => { | |
| 475 | - | try { | |
| 476 | - | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 477 | - | credentials: 'include' | |
| 478 | - | }); | |
| 479 | - | if (userResponse.ok) { | |
| 480 | - | const userData = await userResponse.json(); | |
| 481 | - | if (userData.success && userData.user) { | |
| 482 | - | this.userCache[userId] = userData.user; | |
| 483 | - | if (!this.users.some((u) => u.id === userId)) { | |
| 484 | - | this.users = [...this.users, userData.user]; | |
| 485 | - | } | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | } catch (error) { | |
| 489 | - | console.debug('Error fetching user data:', { userId, error }); | |
| 490 | - | } | |
| 491 | - | }) | |
| 492 | - | ); | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 496 | - | ||
| 497 | - | if ('hasMore' in data) { | |
| 498 | - | this.hasMoreMessages = data.hasMore; | |
| 499 | - | } | |
| 500 | - | ||
| 501 | - | console.debug('Messages initialized:', { | |
| 502 | - | count: data.messages.length, | |
| 503 | - | isAuthenticated: !!this.currentUser, | |
| 504 | - | hasMore: this.hasMoreMessages | |
| 505 | - | }); | |
| 506 | - | } catch (error) { | |
| 507 | - | console.debug('Error fetching initial messages:', error); | |
| 508 | - | this.messages = []; | |
| 509 | - | this.hasMoreMessages = false; | |
| 510 | - | } | |
| 511 | - | } | |
| 512 | - | ||
| 513 | - | /** | |
| 514 | - | * Send a message | |
| 515 | - | */ | |
| 516 | - | async sendMessage( | |
| 517 | - | content: string, | |
| 518 | - | type: Message['type'] = 'chat', | |
| 519 | - | textStyle?: TextStyle | |
| 520 | - | ): Promise<SendMessageResponse> { | |
| 521 | - | const user = this.getCurrentUser(); | |
| 522 | - | if (!user) { | |
| 523 | - | console.debug('Cannot send message: No current user'); | |
| 524 | - | return { | |
| 525 | - | success: false, | |
| 526 | - | error: 'Not logged in' | |
| 527 | - | }; | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | try { | |
| 531 | - | const payload: SendMessageRequest = { | |
| 532 | - | content, | |
| 533 | - | type, | |
| 534 | - | userId: user.id, | |
| 535 | - | chatRoomId: DEFAULT_CHAT_ROOM_ID, | |
| 536 | - | styleData: textStyle ? JSON.stringify(textStyle) : undefined | |
| 537 | - | }; | |
| 538 | - | ||
| 539 | - | console.debug('Sending message with payload:', payload); | |
| 540 | - | ||
| 541 | - | const response = await fetch('/api/chat/messages', { | |
| 542 | - | method: 'POST', | |
| 543 | - | headers: { | |
| 544 | - | 'Content-Type': 'application/json' | |
| 545 | - | }, | |
| 546 | - | credentials: 'include', | |
| 547 | - | body: JSON.stringify(payload) | |
| 548 | - | }); | |
| 549 | - | ||
| 550 | - | if (!response.ok) { | |
| 551 | - | const errorData = await response.json(); | |
| 552 | - | throw new Error(errorData.error || 'Failed to save message'); | |
| 553 | - | } | |
| 554 | - | ||
| 555 | - | const data = (await response.json()) as SendMessageResponse; | |
| 556 | - | if (!data.success) { | |
| 557 | - | throw new Error(data.error); | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | console.debug('Message sent successfully:', data.message); | |
| 561 | - | ||
| 562 | - | // Add message to state immediately for instant display (optimistic update) | |
| 563 | - | // SSE deduplication will handle any duplicate when the broadcast arrives | |
| 564 | - | if (data.message) { | |
| 565 | - | this.messages = Array.from( | |
| 566 | - | new Map([...this.messages, data.message].map((m) => [m.id, m])).values() | |
| 567 | - | ); | |
| 568 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | await invalidate('chat:messages'); | |
| 572 | - | return data; | |
| 573 | - | } catch (error) { | |
| 574 | - | console.debug('Error sending message:', error); | |
| 575 | - | return { | |
| 576 | - | success: false, | |
| 577 | - | error: 'Failed to send message' | |
| 578 | - | }; | |
| 579 | - | } | |
| 580 | - | } | |
| 581 | - | ||
| 582 | - | /** | |
| 583 | - | * Set up SSE connection using the new SSEClient | |
| 584 | - | */ | |
| 585 | - | private setupSSE() { | |
| 586 | - | if (this.sseClient && this.sseClient.isConnected) { | |
| 587 | - | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 35 | + | this._currentUser = null; | |
| 36 | + | this._connectionStatus = 'disconnected'; | |
| 588 | 37 | return; | |
| 589 | 38 | } | |
| 590 | 39 | ||
| 591 | - | if (!this.currentUser) { | |
| 592 | - | console.debug('No current user, skipping SSE connection'); | |
| 593 | - | return; | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | console.debug('Setting up SSE connection...'); | |
| 597 | - | ||
| 598 | - | const config: SSEClientConfig = { | |
| 599 | - | url: '/api/sse', | |
| 600 | - | onMessage: this.handleSSEMessage.bind(this), | |
| 601 | - | onStatusChange: this.handleConnectionStatusChange.bind(this), | |
| 602 | - | onError: (error) => { | |
| 603 | - | this._sseError = error; | |
| 604 | - | console.debug('SSE error:', error); | |
| 605 | - | } | |
| 606 | - | }; | |
| 607 | - | ||
| 608 | - | this.sseClient = new SSEClient(config); | |
| 609 | - | this.sseClient.connect(); | |
| 40 | + | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 41 | + | this._currentUser = safeUser; | |
| 42 | + | this._connectionStatus = 'connected'; | |
| 610 | 43 | } | |
| 611 | 44 | ||
| 612 | 45 | /** | |
| 613 | - | * Handle incoming SSE messages | |
| 46 | + | * Get the current room ID | |
| 614 | 47 | */ | |
| 615 | - | private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) { | |
| 616 | - | switch (type) { | |
| 617 | - | case 'chatMessage': | |
| 618 | - | this.handleChatMessage(data as SSEEventMap['chatMessage']); | |
| 619 | - | break; | |
| 620 | - | case 'buddyListUpdate': | |
| 621 | - | this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']); | |
| 622 | - | break; | |
| 623 | - | case 'userStatusUpdate': { | |
| 624 | - | const statusData = data as SSEEventMap['userStatusUpdate']; | |
| 625 | - | this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen); | |
| 626 | - | break; | |
| 627 | - | } | |
| 628 | - | case 'heartbeat': | |
| 629 | - | // Heartbeat is handled by SSEClient, no action needed here | |
| 630 | - | break; | |
| 631 | - | default: | |
| 632 | - | console.debug('Unknown SSE event type:', type); | |
| 633 | - | } | |
| 48 | + | getCurrentRoomId() { | |
| 49 | + | return this._currentRoomId; | |
| 634 | 50 | } | |
| 635 | 51 | ||
| 636 | 52 | /** | |
| 637 | - | * Handle chat message from SSE | |
| 53 | + | * Set the current room ID | |
| 638 | 54 | */ | |
| 639 | - | private async handleChatMessage(messageData: Message) { | |
| 640 | - | console.debug('Received chat message via SSE:', messageData); | |
| 641 | - | ||
| 642 | - | // Deduplicate and update messages array | |
| 643 | - | this.messages = Array.from( | |
| 644 | - | new Map([...this.messages, messageData].map((m) => [m.id, m])).values() | |
| 645 | - | ); | |
| 646 | - | this.messages.sort((a, b) => a.timestamp - b.timestamp); | |
| 647 | - | ||
| 648 | - | // Ensure the sender's data is available | |
| 649 | - | await this.ensureUserData(messageData.senderId); | |
| 55 | + | setCurrentRoomId(roomId: string | null) { | |
| 56 | + | this._currentRoomId = roomId; | |
| 650 | 57 | } | |
| 651 | 58 | ||
| 652 | 59 | /** | |
| 653 | - | * Handle connection status changes | |
| 60 | + | * Set connection status | |
| 654 | 61 | */ | |
| 655 | - | private handleConnectionStatusChange(status: ConnectionStatus) { | |
| 62 | + | setConnectionStatus(status: 'connected' | 'disconnected' | 'reconnecting') { | |
| 656 | 63 | this._connectionStatus = status; | |
| 657 | - | console.debug('SSE connection status changed:', status); | |
| 658 | - | ||
| 659 | - | if (status === 'connected') { | |
| 660 | - | this._sseError = null; | |
| 661 | - | } | |
| 662 | - | } | |
| 663 | - | ||
| 664 | - | /** | |
| 665 | - | * Ensure user data is loaded | |
| 666 | - | */ | |
| 667 | - | private async ensureUserData(userId: string) { | |
| 668 | - | if (this.userCache[userId]) return; | |
| 669 | - | ||
| 670 | - | console.debug('Fetching user data for:', userId); | |
| 671 | - | try { | |
| 672 | - | const response = await fetch(`/api/users/${userId}`, { | |
| 673 | - | credentials: 'include' | |
| 674 | - | }); | |
| 675 | - | if (response.ok) { | |
| 676 | - | const userData = await response.json(); | |
| 677 | - | if (userData.success && userData.user) { | |
| 678 | - | this.userCache[userId] = userData.user; | |
| 679 | - | if (!this.users.some((u) => u.id === userId)) { | |
| 680 | - | this.users = [...this.users, userData.user]; | |
| 681 | - | } | |
| 682 | - | } | |
| 683 | - | } | |
| 684 | - | } catch (error) { | |
| 685 | - | console.debug('Error fetching user data:', error); | |
| 686 | - | } | |
| 687 | - | } | |
| 688 | - | ||
| 689 | - | /** | |
| 690 | - | * Enrich messages with user data | |
| 691 | - | */ | |
| 692 | - | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 693 | - | console.debug('Enriching messages with user data', { | |
| 694 | - | messagesCount: messages.length, | |
| 695 | - | userCacheSize: Object.keys(this.userCache).length | |
| 696 | - | }); | |
| 697 | - | return messages.map((message) => ({ | |
| 698 | - | ...message, | |
| 699 | - | user: this.userCache[message.senderId] || { | |
| 700 | - | id: message.senderId, | |
| 701 | - | nickname: 'Unknown User', | |
| 702 | - | status: 'offline', | |
| 703 | - | avatarUrl: null, | |
| 704 | - | lastSeen: null | |
| 705 | - | } | |
| 706 | - | })); | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | /** | |
| 710 | - | * Get the default chat room ID | |
| 711 | - | */ | |
| 712 | - | getDefaultChatRoomId() { | |
| 713 | - | return DEFAULT_CHAT_ROOM_ID; | |
| 714 | - | } | |
| 715 | - | ||
| 716 | - | /** | |
| 717 | - | * Disconnect SSE (for cleanup) | |
| 718 | - | */ | |
| 719 | - | disconnect() { | |
| 720 | - | if (this.sseClient) { | |
| 721 | - | this.sseClient.disconnect(); | |
| 722 | - | this.sseClient = null; | |
| 723 | - | } | |
| 724 | - | if (this.publicPollingInterval) { | |
| 725 | - | clearInterval(this.publicPollingInterval); | |
| 726 | - | this.publicPollingInterval = null; | |
| 727 | - | } | |
| 728 | 64 | } | |
| 729 | 65 | } | |
| 730 | 66 | ||
M src/lib/utils/chat.server.ts +12 -43
| @@ -1,56 +1,25 @@ | |||
| 1 | - | import type { LibSQLDatabase } from 'drizzle-orm/libsql'; | |
| 2 | 1 | import { createLogger } from '$lib/utils/logger.server'; | |
| 3 | - | import schema, { chatRooms } from '$lib/db/schema'; | |
| 4 | - | import { eq } from 'drizzle-orm'; | |
| 5 | - | import { env } from '$env/dynamic/public'; | |
| 2 | + | import { chatRooms } from '$lib/db/convex.server'; | |
| 6 | 3 | ||
| 7 | 4 | const log = createLogger('chat-utils'); | |
| 8 | 5 | ||
| 9 | - | // Default chat room ID from environment variable | |
| 10 | - | export const DEFAULT_CHAT_ROOM_ID = | |
| 11 | - | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 6 | + | // Default chat room ID - will be fetched/created on startup | |
| 7 | + | let defaultChatRoomId: string | null = null; | |
| 12 | 8 | ||
| 13 | - | // Type guard for LibSQL errors | |
| 14 | - | interface LibSQLError { | |
| 15 | - | code: string; | |
| 16 | - | message: string; | |
| 9 | + | // Function to get the default chat room ID | |
| 10 | + | export function getDefaultChatRoomId(): string | null { | |
| 11 | + | return defaultChatRoomId; | |
| 17 | 12 | } | |
| 18 | 13 | ||
| 19 | - | function isLibSQLError(error: unknown): error is LibSQLError { | |
| 20 | - | return typeof error === 'object' && error !== null && 'code' in error; | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // Function to ensure default chat room exists | |
| 24 | - | export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) { | |
| 14 | + | // Function to ensure default chat room exists (using Convex) | |
| 15 | + | export async function ensureDefaultChatRoom(): Promise<string> { | |
| 25 | 16 | log.debug('Ensuring default chat room exists...'); | |
| 26 | 17 | try { | |
| 27 | - | // Use a transaction to handle concurrent creation attempts | |
| 28 | - | await db.transaction(async (tx) => { | |
| 29 | - | const defaultRoom = await tx | |
| 30 | - | .select() | |
| 31 | - | .from(chatRooms) | |
| 32 | - | .where(eq(chatRooms.id, DEFAULT_CHAT_ROOM_ID)) | |
| 33 | - | .get(); | |
| 34 | - | ||
| 35 | - | if (!defaultRoom) { | |
| 36 | - | log.debug('Creating default chat room...'); | |
| 37 | - | await tx.insert(chatRooms).values({ | |
| 38 | - | id: DEFAULT_CHAT_ROOM_ID, | |
| 39 | - | name: 'General', | |
| 40 | - | type: 'group', | |
| 41 | - | createdAt: Date.now() | |
| 42 | - | }); | |
| 43 | - | log.debug('Default chat room created successfully'); | |
| 44 | - | } else { | |
| 45 | - | log.debug('Default chat room already exists'); | |
| 46 | - | } | |
| 47 | - | }); | |
| 18 | + | const room = await chatRooms.getOrCreateDefault(); | |
| 19 | + | defaultChatRoomId = room._id; | |
| 20 | + | log.debug('Default chat room ready', { id: defaultChatRoomId }); | |
| 21 | + | return defaultChatRoomId; | |
| 48 | 22 | } catch (error: unknown) { | |
| 49 | - | // If the error is a unique constraint violation, another process probably created the room | |
| 50 | - | if (isLibSQLError(error) && error.code === 'SQLITE_CONSTRAINT') { | |
| 51 | - | log.debug('Default chat room was created by another process'); | |
| 52 | - | return; | |
| 53 | - | } | |
| 54 | 23 | log.error('Error ensuring default chat room:', { | |
| 55 | 24 | error: error instanceof Error ? error.message : 'Unknown error' | |
| 56 | 25 | }); | |
M src/lib/utils/password.ts +16 -1
| @@ -23,6 +23,21 @@ function hexToBuffer(hex: string): Uint8Array { | |||
| 23 | 23 | return arr; | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | /** | |
| 27 | + | * Performs a constant-time string comparison to prevent timing attacks. | |
| 28 | + | * Returns true if strings are equal, false otherwise. | |
| 29 | + | */ | |
| 30 | + | function constantTimeCompare(a: string, b: string): boolean { | |
| 31 | + | if (a.length !== b.length) { | |
| 32 | + | return false; | |
| 33 | + | } | |
| 34 | + | let result = 0; | |
| 35 | + | for (let i = 0; i < a.length; i++) { | |
| 36 | + | result |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 37 | + | } | |
| 38 | + | return result === 0; | |
| 39 | + | } | |
| 40 | + | ||
| 26 | 41 | /** | |
| 27 | 42 | * Derives a password hash using PBKDF2 with the provided salt. | |
| 28 | 43 | * @param password The plain text password. | |
| @@ -70,5 +85,5 @@ export async function verifyPassword(password: string, hash: string): Promise<bo | |||
| 70 | 85 | const [saltHex, hashValue] = hash.split(':'); | |
| 71 | 86 | const salt = hexToBuffer(saltHex); | |
| 72 | 87 | const derivedHash = await derivePasswordHash(password, salt); | |
| 73 | - | return derivedHash === hashValue; | |
| 88 | + | return constantTimeCompare(derivedHash, hashValue); | |
| 74 | 89 | } | |
M src/routes/+layout.svelte +9 -5
| @@ -1,9 +1,13 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import '../app.css'; | |
| 3 | + | import { setupConvex } from 'convex-svelte'; | |
| 4 | + | import { PUBLIC_CONVEX_URL } from '$env/static/public'; | |
| 3 | 5 | import { chatState } from '$lib/states/chat.svelte'; | |
| 4 | 6 | import { onMount } from 'svelte'; | |
| 5 | 7 | import { browser } from '$app/environment'; | |
| 6 | - | import { SSE_CONFIG } from '$lib/sse/config'; | |
| 8 | + | ||
| 9 | + | // Initialize Convex client for real-time subscriptions | |
| 10 | + | setupConvex(PUBLIC_CONVEX_URL); | |
| 7 | 11 | ||
| 8 | 12 | let { children, data } = $props(); | |
| 9 | 13 | let isVisible = $state(true); | |
| @@ -12,14 +16,14 @@ | |||
| 12 | 16 | let updateInProgress = $state(false); | |
| 13 | 17 | let idleTimeout = $state<number | undefined>(undefined); | |
| 14 | 18 | ||
| 15 | - | // Timing constants from SSE config for consistency | |
| 19 | + | // Status update timing constants | |
| 16 | 20 | let lastSentStatus = $state<string | null>(null); | |
| 17 | 21 | let lastStatusSentAt = $state<number>(0); | |
| 18 | 22 | let lastUserActivity = $state<number>(Date.now()); | |
| 19 | - | const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds | |
| 23 | + | const STATUS_UPDATE_INTERVAL = 30000; // 30 seconds | |
| 20 | 24 | const THROTTLE_TIME_MS = 10000; // 10 seconds | |
| 21 | - | const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes | |
| 22 | - | const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server) | |
| 25 | + | const FORCE_UPDATE_INTERVAL = 5 * 60 * 1000; // 5 minutes | |
| 26 | + | const IDLE_TIMEOUT = 2 * 60 * 1000; // 2 minutes of inactivity = idle | |
| 23 | 27 | ||
| 24 | 28 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | |
| 25 | 29 | ||
M src/routes/+page.server.ts +22 -31
| @@ -1,9 +1,11 @@ | |||
| 1 | 1 | import type { PageServerLoad } from './$types'; | |
| 2 | 2 | import { validateSessionToken } from '$lib/api/session.server'; | |
| 3 | - | import db from '$lib/db/db.server'; | |
| 4 | - | import { messages } from '$lib/db/schema'; | |
| 5 | - | import { eq, desc } from 'drizzle-orm'; | |
| 6 | - | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |
| 3 | + | import { | |
| 4 | + | DEFAULT_TEXT_STYLE, | |
| 5 | + | DEFAULT_USER_PREFERENCES, | |
| 6 | + | type TextStyle | |
| 7 | + | } from '$lib/types/text-formatting'; | |
| 8 | + | import convex from '$lib/db/convex.server'; | |
| 7 | 9 | ||
| 8 | 10 | export const load: PageServerLoad = async ({ cookies }) => { | |
| 9 | 11 | const token = cookies.get('session'); | |
| @@ -11,7 +13,8 @@ export const load: PageServerLoad = async ({ cookies }) => { | |||
| 11 | 13 | if (!token) { | |
| 12 | 14 | return { | |
| 13 | 15 | user: null, | |
| 14 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 16 | + | textStyle: DEFAULT_TEXT_STYLE, | |
| 17 | + | userPreferences: DEFAULT_USER_PREFERENCES | |
| 15 | 18 | }; | |
| 16 | 19 | } | |
| 17 | 20 | ||
| @@ -21,43 +24,31 @@ export const load: PageServerLoad = async ({ cookies }) => { | |||
| 21 | 24 | if (!user) { | |
| 22 | 25 | return { | |
| 23 | 26 | user: null, | |
| 24 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 27 | + | textStyle: DEFAULT_TEXT_STYLE, | |
| 28 | + | userPreferences: DEFAULT_USER_PREFERENCES | |
| 25 | 29 | }; | |
| 26 | 30 | } | |
| 27 | 31 | ||
| 28 | - | // Get user's last used text styling from their most recent message | |
| 29 | - | const lastMessage = await db.query.messages.findFirst({ | |
| 30 | - | where: eq(messages.senderId, user.id), | |
| 31 | - | orderBy: [desc(messages.timestamp)], | |
| 32 | - | columns: { | |
| 33 | - | styleData: true | |
| 34 | - | } | |
| 35 | - | }); | |
| 36 | - | ||
| 37 | - | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |
| 38 | - | ||
| 39 | - | if (lastMessage?.styleData) { | |
| 40 | - | try { | |
| 41 | - | const parsedStyle = JSON.parse(lastMessage.styleData); | |
| 42 | - | // Merge with default style to ensure all required properties | |
| 43 | - | lastTextStyle = { | |
| 44 | - | ...DEFAULT_TEXT_STYLE, | |
| 45 | - | ...parsedStyle | |
| 46 | - | }; | |
| 47 | - | } catch (error) { | |
| 48 | - | console.warn('Failed to parse last text style:', error); | |
| 49 | - | } | |
| 50 | - | } | |
| 32 | + | // Fetch user's text preferences from Convex | |
| 33 | + | const prefs = await convex.textPreferences.get(user.id); | |
| 51 | 34 | ||
| 52 | 35 | return { | |
| 53 | 36 | user, | |
| 54 | - | lastTextStyle | |
| 37 | + | textStyle: (prefs?.defaultStyle as TextStyle) ?? DEFAULT_TEXT_STYLE, | |
| 38 | + | userPreferences: prefs | |
| 39 | + | ? { | |
| 40 | + | defaultStyle: prefs.defaultStyle as TextStyle, | |
| 41 | + | allowFormatting: prefs.allowFormatting, | |
| 42 | + | maxMessageLength: prefs.maxMessageLength | |
| 43 | + | } | |
| 44 | + | : DEFAULT_USER_PREFERENCES | |
| 55 | 45 | }; | |
| 56 | 46 | } catch (error) { | |
| 57 | 47 | console.error('Error loading page data:', error); | |
| 58 | 48 | return { | |
| 59 | 49 | user: null, | |
| 60 | - | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 50 | + | textStyle: DEFAULT_TEXT_STYLE, | |
| 51 | + | userPreferences: DEFAULT_USER_PREFERENCES | |
| 61 | 52 | }; | |
| 62 | 53 | } | |
| 63 | 54 | }; | |
M src/routes/+page.svelte +1 -1
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | <DesktopIcons bind:showChatRoom bind:showAuth /> | |
| 19 | 19 | ||
| 20 | 20 | {#if showChatRoom} | |
| 21 | - | <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} /> | |
| 21 | + | <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} /> | |
| 22 | 22 | {/if} | |
| 23 | 23 | ||
| 24 | 24 | {#if showAuth} | |
M src/routes/api/chat/messages/+server.ts +65 -96
| @@ -1,18 +1,12 @@ | |||
| 1 | - | import { v4 as uuidv4 } from 'uuid'; | |
| 2 | - | import db from '$lib/db/db.server'; | |
| 3 | - | import { messages } from '$lib/db/schema'; | |
| 1 | + | import { messages } from '$lib/db/convex.server'; | |
| 2 | + | import { getDefaultChatRoomId } from '$lib/utils/chat.server'; | |
| 4 | 3 | import type { Message } from '$lib/types/chat'; | |
| 5 | 4 | import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads'; | |
| 6 | - | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | |
| 7 | - | import type { SQL } from 'drizzle-orm/sql'; | |
| 8 | 5 | import { error } from '@sveltejs/kit'; | |
| 9 | - | import { sseEmitter } from '$lib/sse/SSEEventEmitter'; | |
| 10 | - | import { chatRooms } from '$lib/db/schema'; | |
| 11 | - | import { users } from '$lib/db/schema'; | |
| 12 | - | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | |
| 13 | 6 | import { createLogger } from '$lib/utils/logger.server'; | |
| 14 | 7 | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |
| 15 | 8 | import { sendMessageSchema } from '$lib/validation/message'; | |
| 9 | + | import type { RequestHandler } from './$types'; | |
| 16 | 10 | ||
| 17 | 11 | const log = createLogger('chat-server'); | |
| 18 | 12 | ||
| @@ -31,7 +25,6 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu | |||
| 31 | 25 | currentCooldown: INITIAL_COOLDOWN | |
| 32 | 26 | }; | |
| 33 | 27 | ||
| 34 | - | // Check if enough time has passed since the last message | |
| 35 | 28 | const timeSinceLastMessage = now - userState.lastMessageTime; | |
| 36 | 29 | if (timeSinceLastMessage < userState.currentCooldown) { | |
| 37 | 30 | return { | |
| @@ -40,11 +33,9 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu | |||
| 40 | 33 | }; | |
| 41 | 34 | } | |
| 42 | 35 | ||
| 43 | - | // Reset cooldown if it's been long enough | |
| 44 | 36 | if (timeSinceLastMessage > userState.currentCooldown * 2) { | |
| 45 | 37 | userState.currentCooldown = INITIAL_COOLDOWN; | |
| 46 | 38 | } else { | |
| 47 | - | // Exponential backoff | |
| 48 | 39 | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | |
| 49 | 40 | } | |
| 50 | 41 | ||
| @@ -54,68 +45,65 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu | |||
| 54 | 45 | return { canSend: true }; | |
| 55 | 46 | } | |
| 56 | 47 | ||
| 57 | - | // GET endpoint: fetch messages directly from DB | |
| 58 | - | export async function GET({ request, locals }) { | |
| 59 | - | const url = new URL(request.url); | |
| 48 | + | // GET endpoint: fetch messages from Convex | |
| 49 | + | export const GET: RequestHandler = async ({ url, locals }) => { | |
| 60 | 50 | const beforeTimestamp = url.searchParams.get('before'); | |
| 61 | - | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 51 | + | const roomIdParam = url.searchParams.get('roomId'); | |
| 62 | 52 | const isPublic = url.searchParams.get('public') === 'true'; | |
| 63 | 53 | ||
| 54 | + | // Use default room if not specified | |
| 55 | + | const roomId = roomIdParam || getDefaultChatRoomId(); | |
| 56 | + | ||
| 57 | + | if (!roomId) { | |
| 58 | + | log.error('No room ID available'); | |
| 59 | + | throw error(500, 'Chat room not configured'); | |
| 60 | + | } | |
| 61 | + | ||
| 64 | 62 | try { | |
| 65 | - | log.debug('Fetching messages from database', { | |
| 63 | + | log.debug('Fetching messages from Convex', { | |
| 66 | 64 | beforeTimestamp, | |
| 67 | 65 | roomId, | |
| 68 | 66 | isAuthenticated: !!locals.session, | |
| 69 | 67 | isPublic | |
| 70 | 68 | }); | |
| 71 | 69 | ||
| 72 | - | let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>; | |
| 73 | - | ||
| 74 | - | if (beforeTimestamp) { | |
| 75 | - | conditions = and( | |
| 76 | - | conditions, | |
| 77 | - | lt(messages.timestamp, parseInt(beforeTimestamp)) | |
| 78 | - | ) as SQL<unknown>; | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Fetch messages with a limit based on authentication status | |
| 82 | - | // Public requests are limited to 50 messages | |
| 83 | - | // Authenticated users get 100 messages per request | |
| 84 | 70 | const fetchLimit = isPublic || !locals.session ? 50 : 100; | |
| 85 | 71 | ||
| 86 | - | const fetchedMessages = await db | |
| 87 | - | .select() | |
| 88 | - | .from(messages) | |
| 89 | - | .where(conditions) | |
| 90 | - | .orderBy(desc(messages.timestamp)) | |
| 91 | - | .limit(fetchLimit) | |
| 92 | - | .then((rows) => | |
| 93 | - | rows.map((row) => ({ | |
| 94 | - | ...row, | |
| 95 | - | styleData: row.styleData ?? undefined, | |
| 96 | - | hasFormatting: row.hasFormatting ?? false | |
| 97 | - | })) | |
| 98 | - | ); | |
| 72 | + | const result = await messages.getByRoom( | |
| 73 | + | roomId, | |
| 74 | + | fetchLimit, | |
| 75 | + | beforeTimestamp ? parseInt(beforeTimestamp) : undefined | |
| 76 | + | ); | |
| 77 | + | ||
| 78 | + | // Map Convex messages to our Message type | |
| 79 | + | const fetchedMessages: Message[] = result.messages.map((msg) => ({ | |
| 80 | + | id: msg._id, | |
| 81 | + | chatRoomId: msg.chatRoomId, | |
| 82 | + | senderId: msg.senderId, | |
| 83 | + | content: msg.content, | |
| 84 | + | type: msg.type as Message['type'], | |
| 85 | + | timestamp: msg.timestamp, | |
| 86 | + | styleData: msg.styleData, | |
| 87 | + | hasFormatting: msg.hasFormatting ?? false | |
| 88 | + | })); | |
| 99 | 89 | ||
| 100 | 90 | const response: GetMessagesResponse = { | |
| 101 | 91 | success: true, | |
| 102 | 92 | messages: fetchedMessages, | |
| 103 | - | hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages | |
| 93 | + | hasMore: result.hasMore | |
| 104 | 94 | }; | |
| 105 | 95 | ||
| 106 | 96 | return new Response(JSON.stringify(response), { | |
| 107 | - | headers: { | |
| 108 | - | 'Content-Type': 'application/json' | |
| 109 | - | } | |
| 97 | + | headers: { 'Content-Type': 'application/json' } | |
| 110 | 98 | }); | |
| 111 | 99 | } catch (err) { | |
| 112 | 100 | log.error('Error fetching messages:', { error: err }); | |
| 113 | 101 | throw error(500, 'Failed to fetch messages'); | |
| 114 | 102 | } | |
| 115 | - | } | |
| 103 | + | }; | |
| 116 | 104 | ||
| 117 | - | // POST endpoint: receive a new message, save it in the DB, and broadcast via SSE | |
| 118 | - | export async function POST({ request, locals }: { request: Request; locals: App.Locals }) { | |
| 105 | + | // POST endpoint: send a new message via Convex | |
| 106 | + | export const POST: RequestHandler = async ({ request, locals }) => { | |
| 119 | 107 | if (!locals.user) { | |
| 120 | 108 | log.warn('Authentication required'); | |
| 121 | 109 | throw error(401, 'Authentication required'); | |
| @@ -170,61 +158,42 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||
| 170 | 158 | throw error(403, 'Cannot post messages as another user'); | |
| 171 | 159 | } | |
| 172 | 160 | ||
| 173 | - | const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID; | |
| 174 | - | ||
| 175 | - | // Check chat room existence | |
| 176 | - | const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get(); | |
| 161 | + | // Use default room if not specified | |
| 162 | + | const chatRoomId = data.chatRoomId || getDefaultChatRoomId(); | |
| 177 | 163 | ||
| 178 | - | if (!chatRoom) { | |
| 179 | - | log.warn('Chat room not found', { chatRoomId }); | |
| 164 | + | if (!chatRoomId) { | |
| 165 | + | log.error('No chat room ID available'); | |
| 180 | 166 | const errorResponse: SendMessageResponse = { | |
| 181 | 167 | success: false, | |
| 182 | - | error: 'Chat room not found' | |
| 168 | + | error: 'Chat room not configured' | |
| 183 | 169 | }; | |
| 184 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // Check user existence | |
| 188 | - | const user = await db.select().from(users).where(eq(users.id, data.userId)).get(); | |
| 189 | - | ||
| 190 | - | if (!user) { | |
| 191 | - | const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`; | |
| 192 | - | log.warn('User not found', { maskedUserId }); | |
| 193 | - | const errorResponse: SendMessageResponse = { | |
| 194 | - | success: false, | |
| 195 | - | error: 'User not found' | |
| 196 | - | }; | |
| 197 | - | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 170 | + | return new Response(JSON.stringify(errorResponse), { status: 500 }); | |
| 198 | 171 | } | |
| 199 | 172 | ||
| 200 | 173 | const validatedStyleData = sanitizeStyleData(data.styleData); | |
| 201 | 174 | ||
| 202 | - | const newMessage: Message = { | |
| 203 | - | id: uuidv4(), | |
| 175 | + | // Send message via Convex | |
| 176 | + | const convexMessage = await messages.send( | |
| 204 | 177 | chatRoomId, | |
| 205 | - | senderId: data.userId, | |
| 206 | - | content: data.content, | |
| 207 | - | type: data.type || 'chat', | |
| 208 | - | timestamp: Date.now(), | |
| 209 | - | styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, | |
| 210 | - | hasFormatting: Boolean(validatedStyleData) | |
| 178 | + | data.userId, | |
| 179 | + | data.content, | |
| 180 | + | data.type || 'chat', | |
| 181 | + | validatedStyleData ? JSON.stringify(validatedStyleData) : undefined, | |
| 182 | + | Boolean(validatedStyleData) | |
| 183 | + | ); | |
| 184 | + | ||
| 185 | + | // Map to our Message type | |
| 186 | + | const newMessage: Message = { | |
| 187 | + | id: convexMessage._id, | |
| 188 | + | chatRoomId: convexMessage.chatRoomId, | |
| 189 | + | senderId: convexMessage.senderId, | |
| 190 | + | content: convexMessage.content, | |
| 191 | + | type: convexMessage.type as Message['type'], | |
| 192 | + | timestamp: convexMessage.timestamp, | |
| 193 | + | styleData: convexMessage.styleData, | |
| 194 | + | hasFormatting: convexMessage.hasFormatting ?? false | |
| 211 | 195 | }; | |
| 212 | 196 | ||
| 213 | - | // debug style data content at this point | |
| 214 | - | log.debug('Style data content', { styleData: validatedStyleData }); | |
| 215 | - | ||
| 216 | - | // Save to DB | |
| 217 | - | await db.insert(messages).values(newMessage); | |
| 218 | - | log.debug('Message saved in DB', { | |
| 219 | - | messageId: newMessage.id, | |
| 220 | - | chatRoomId: newMessage.chatRoomId, | |
| 221 | - | type: newMessage.type, | |
| 222 | - | timestamp: newMessage.timestamp | |
| 223 | - | }); | |
| 224 | - | ||
| 225 | - | // Broadcast the new message via SSE | |
| 226 | - | sseEmitter.emit('chatMessage', newMessage); | |
| 227 | - | ||
| 228 | 197 | log.debug('Message processed successfully', { | |
| 229 | 198 | messageId: newMessage.id, | |
| 230 | 199 | userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, | |
| @@ -240,9 +209,9 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||
| 240 | 209 | status: 201, | |
| 241 | 210 | headers: { 'Content-Type': 'application/json' } | |
| 242 | 211 | }); | |
| 243 | - | } catch (error) { | |
| 212 | + | } catch (err) { | |
| 244 | 213 | log.error('Error processing message', { | |
| 245 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 214 | + | error: err instanceof Error ? err.message : 'Unknown error' | |
| 246 | 215 | }); | |
| 247 | 216 | const errorResponse: SendMessageResponse = { | |
| 248 | 217 | success: false, | |
| @@ -253,4 +222,4 @@ export async function POST({ request, locals }: { request: Request; locals: App. | |||
| 253 | 222 | headers: { 'Content-Type': 'application/json' } | |
| 254 | 223 | }); | |
| 255 | 224 | } | |
| 256 | - | } | |
| 225 | + | }; | |
M src/routes/api/register/+server.ts +7 -13
| @@ -1,11 +1,9 @@ | |||
| 1 | 1 | import type { RequestHandler } from '@sveltejs/kit'; | |
| 2 | 2 | import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads'; | |
| 3 | - | import db from '$lib/db/db.server'; | |
| 4 | - | import { users } from '$lib/db/schema'; | |
| 3 | + | import { users } from '$lib/db/convex.server'; | |
| 5 | 4 | import { hashPassword } from '$lib/utils/password'; | |
| 6 | 5 | import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password'; | |
| 7 | 6 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | - | import { eq } from 'drizzle-orm/sql'; | |
| 9 | 7 | ||
| 10 | 8 | const log = createLogger('register-server'); | |
| 11 | 9 | ||
| @@ -102,9 +100,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 102 | 100 | const username = suUsername.trim(); | |
| 103 | 101 | const password = suPassword.trim(); | |
| 104 | 102 | ||
| 105 | - | // Check if nickname already exists | |
| 106 | - | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | |
| 107 | - | if (existingUser.length > 0) { | |
| 103 | + | // Check if nickname already exists using Convex | |
| 104 | + | const existingUser = await users.getByNickname(username); | |
| 105 | + | if (existingUser) { | |
| 108 | 106 | log.warn('Username already exists', { username }); | |
| 109 | 107 | return new Response( | |
| 110 | 108 | JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError), | |
| @@ -123,17 +121,13 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 123 | 121 | { status: 500 } | |
| 124 | 122 | ); | |
| 125 | 123 | } | |
| 126 | - | // Insert the new user into the database. | |
| 124 | + | ||
| 125 | + | // Insert the new user into Convex | |
| 127 | 126 | try { | |
| 128 | - | await db.insert(users).values({ | |
| 129 | - | nickname: username, | |
| 130 | - | password: hashedPassword, | |
| 131 | - | createdAt: Date.now() | |
| 132 | - | }); | |
| 127 | + | await users.create(username, hashedPassword); | |
| 133 | 128 | log.info(`User ${username} registered successfully`); | |
| 134 | 129 | } catch (err: unknown) { | |
| 135 | 130 | log.error('Database insertion error', { error: err as object }); | |
| 136 | - | // Assume a duplicate user error if the email (or derived unique field) already exists. | |
| 137 | 131 | return new Response( | |
| 138 | 132 | JSON.stringify({ | |
| 139 | 133 | error: 'User registration failed. Possibly user already exists.' | |
M src/routes/api/rooms/[roomId]/+server.ts +31 -37
| @@ -1,16 +1,15 @@ | |||
| 1 | - | import { eq } from 'drizzle-orm/sql'; | |
| 2 | - | import db from '$lib/db/db.server'; | |
| 3 | - | import { users } from '$lib/db/schema'; | |
| 1 | + | import { users } from '$lib/db/convex.server'; | |
| 4 | 2 | import type { SafeUser } from '$lib/types/chat'; | |
| 5 | 3 | import type { PublicRoomResponse } from '$lib/types/payloads'; | |
| 6 | 4 | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | 5 | import { createLogger } from '$lib/utils/logger.server'; | |
| 6 | + | import type { RequestHandler } from './$types'; | |
| 8 | 7 | ||
| 9 | 8 | const log = createLogger('rooms-server'); | |
| 10 | 9 | ||
| 11 | 10 | const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes | |
| 12 | 11 | ||
| 13 | - | export async function GET({ params, url, locals }): Promise<Response> { | |
| 12 | + | export const GET: RequestHandler = async ({ params, url, locals }) => { | |
| 14 | 13 | const { roomId } = params; | |
| 15 | 14 | const isPublic = !locals.user || url.searchParams.get('public') === 'true'; | |
| 16 | 15 | ||
| @@ -26,52 +25,47 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||
| 26 | 25 | // Calculate timeout threshold | |
| 27 | 26 | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 28 | 27 | ||
| 29 | - | // Fetch buddy list: get all users | |
| 30 | - | const fetchedUsers = await db.select().from(users).execute(); | |
| 31 | - | ||
| 32 | - | // Normalize users to ensure that lastSeen is always a number (using 0 if null) | |
| 33 | - | const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({ | |
| 34 | - | ...user, | |
| 35 | - | lastSeen: user.lastSeen ?? 0 | |
| 36 | - | })); | |
| 28 | + | // Fetch all users from Convex | |
| 29 | + | const fetchedUsers = await users.list(); | |
| 37 | 30 | ||
| 38 | 31 | // Process users to mark them as offline if they've timed out | |
| 39 | - | const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => { | |
| 40 | - | const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold; | |
| 32 | + | const processedUsers = fetchedUsers.map((user) => { | |
| 33 | + | const lastSeen = user.lastSeen ?? 0; | |
| 34 | + | const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold; | |
| 41 | 35 | return { | |
| 42 | - | ...user, | |
| 43 | - | status: shouldBeOffline ? 'offline' : user.status | |
| 36 | + | id: user._id, | |
| 37 | + | nickname: user.nickname, | |
| 38 | + | status: shouldBeOffline ? 'offline' : user.status, | |
| 39 | + | avatarUrl: user.avatarUrl, | |
| 40 | + | lastSeen | |
| 44 | 41 | }; | |
| 45 | 42 | }); | |
| 46 | 43 | ||
| 47 | - | // Find users who were originally non-offline but now marked offline | |
| 48 | - | const usersToUpdate = processedUsers.filter( | |
| 49 | - | (user: SafeUser & { lastSeen: number }) => | |
| 50 | - | user.status === 'offline' && | |
| 51 | - | normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !== | |
| 52 | - | 'offline' | |
| 53 | - | ); | |
| 44 | + | // Find users who should be marked offline and update them | |
| 45 | + | const usersToUpdate = processedUsers.filter((user, index) => { | |
| 46 | + | const original = fetchedUsers[index]; | |
| 47 | + | return user.status === 'offline' && original.status !== 'offline'; | |
| 48 | + | }); | |
| 54 | 49 | ||
| 50 | + | // Update timed-out users in Convex | |
| 55 | 51 | if (usersToUpdate.length > 0) { | |
| 56 | - | await Promise.all( | |
| 57 | - | usersToUpdate.map((user: SafeUser & { lastSeen: number }) => | |
| 58 | - | db | |
| 59 | - | .update(users) | |
| 60 | - | .set({ status: 'offline', lastSeen: Date.now() }) | |
| 61 | - | .where(eq(users.id, user.id)) | |
| 62 | - | .execute() | |
| 63 | - | ) | |
| 64 | - | ); | |
| 52 | + | await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline'))); | |
| 65 | 53 | ||
| 66 | 54 | log.debug('Updated offline status for users', { | |
| 67 | 55 | count: usersToUpdate.length, | |
| 68 | - | userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id) | |
| 56 | + | userIds: usersToUpdate.map((u) => u.id) | |
| 69 | 57 | }); | |
| 70 | 58 | } | |
| 71 | 59 | ||
| 72 | - | // Sanitize user data using createSafeUser | |
| 73 | - | const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) => | |
| 74 | - | createSafeUser(user) | |
| 60 | + | // Sanitize user data | |
| 61 | + | const sanitizedUsers: SafeUser[] = processedUsers.map((user) => | |
| 62 | + | createSafeUser({ | |
| 63 | + | id: user.id, | |
| 64 | + | nickname: user.nickname, | |
| 65 | + | status: user.status, | |
| 66 | + | avatarUrl: user.avatarUrl, | |
| 67 | + | lastSeen: user.lastSeen | |
| 68 | + | }) | |
| 75 | 69 | ); | |
| 76 | 70 | ||
| 77 | 71 | const responseData: PublicRoomResponse = { | |
| @@ -104,4 +98,4 @@ export async function GET({ params, url, locals }): Promise<Response> { | |||
| 104 | 98 | headers: { 'Content-Type': 'application/json' } | |
| 105 | 99 | }); | |
| 106 | 100 | } | |
| 107 | - | } | |
| 101 | + | }; | |
A src/routes/api/rooms/default/+server.ts +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | import { chatRooms } from '$lib/db/convex.server'; | |
| 2 | + | import { createLogger } from '$lib/utils/logger.server'; | |
| 3 | + | import type { RequestHandler } from './$types'; | |
| 4 | + | ||
| 5 | + | const log = createLogger('rooms-default-server'); | |
| 6 | + | ||
| 7 | + | export const GET: RequestHandler = async () => { | |
| 8 | + | try { | |
| 9 | + | log.debug('Fetching default room'); | |
| 10 | + | ||
| 11 | + | const room = await chatRooms.getOrCreateDefault(); | |
| 12 | + | ||
| 13 | + | log.debug('Successfully fetched default room', { | |
| 14 | + | roomId: room._id | |
| 15 | + | }); | |
| 16 | + | ||
| 17 | + | return new Response( | |
| 18 | + | JSON.stringify({ | |
| 19 | + | success: true, | |
| 20 | + | roomId: room._id | |
| 21 | + | }), | |
| 22 | + | { | |
| 23 | + | status: 200, | |
| 24 | + | headers: { 'Content-Type': 'application/json' } | |
| 25 | + | } | |
| 26 | + | ); | |
| 27 | + | } catch (error) { | |
| 28 | + | log.error('Error fetching default room', { | |
| 29 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 30 | + | }); | |
| 31 | + | return new Response( | |
| 32 | + | JSON.stringify({ | |
| 33 | + | success: false, | |
| 34 | + | error: 'Failed to fetch default room' | |
| 35 | + | }), | |
| 36 | + | { | |
| 37 | + | status: 500, | |
| 38 | + | headers: { 'Content-Type': 'application/json' } | |
| 39 | + | } | |
| 40 | + | ); | |
| 41 | + | } | |
| 42 | + | }; | |
M src/routes/api/session/login/+server.ts +23 -20
| @@ -1,8 +1,6 @@ | |||
| 1 | 1 | import type { RequestHandler } from '@sveltejs/kit'; | |
| 2 | 2 | import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads'; | |
| 3 | - | import db from '$lib/db/db.server'; | |
| 4 | - | import { users } from '$lib/db/schema'; | |
| 5 | - | import { eq } from 'drizzle-orm/sql'; | |
| 3 | + | import { users } from '$lib/db/convex.server'; | |
| 6 | 4 | import { verifyPassword } from '$lib/utils/password'; | |
| 7 | 5 | import { generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 8 | 6 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| @@ -43,7 +41,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 43 | 41 | } | |
| 44 | 42 | ||
| 45 | 43 | // Get the IP address for rate limiting and Turnstile validation | |
| 46 | - | const ip = request.headers.get('x-forwarded-for') || 'unknown'; | |
| 44 | + | // Prefer Cloudflare's CF-Connecting-IP header, then x-forwarded-for (first IP), then fallback | |
| 45 | + | const cfConnectingIp = request.headers.get('cf-connecting-ip'); | |
| 46 | + | const forwardedFor = request.headers.get('x-forwarded-for') || ''; | |
| 47 | + | const ip = cfConnectingIp || forwardedFor.split(',')[0]?.trim() || 'unknown'; | |
| 47 | 48 | const maskedIp = ip | |
| 48 | 49 | .split('.') | |
| 49 | 50 | .map((octet, idx) => (idx < 3 ? 'xxx' : octet)) | |
| @@ -84,10 +85,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 84 | 85 | }); | |
| 85 | 86 | } | |
| 86 | 87 | ||
| 87 | - | // Find user by username | |
| 88 | - | const user = await db.query.users.findFirst({ | |
| 89 | - | where: eq(users.nickname, username.trim()) | |
| 90 | - | }); | |
| 88 | + | // Find user by username using Convex | |
| 89 | + | const user = await users.getByNickname(username.trim()); | |
| 91 | 90 | ||
| 92 | 91 | if (!user) { | |
| 93 | 92 | attemptData.count++; | |
| @@ -107,6 +106,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 107 | 106 | ); | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 109 | + | // Use native Convex _id | |
| 110 | + | const userId = user._id; | |
| 111 | + | ||
| 110 | 112 | // Verify password | |
| 111 | 113 | try { | |
| 112 | 114 | const isValid = await verifyPassword(password.trim(), user.password); | |
| @@ -117,7 +119,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 117 | 119 | log.warn('Login failed - invalid password', { | |
| 118 | 120 | maskedIp, | |
| 119 | 121 | attemptCount: attemptData.count, | |
| 120 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}` | |
| 122 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}` | |
| 121 | 123 | }); | |
| 122 | 124 | const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count); | |
| 123 | 125 | return new Response( | |
| @@ -139,29 +141,30 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 139 | 141 | ||
| 140 | 142 | // Generate session token and create session | |
| 141 | 143 | const token = generateSessionToken(); | |
| 142 | - | const session = await createSession(token, user.id); | |
| 144 | + | const session = await createSession(token, userId); | |
| 143 | 145 | ||
| 144 | - | // Update user status to online | |
| 145 | - | await db | |
| 146 | - | .update(users) | |
| 147 | - | .set({ | |
| 148 | - | status: 'online', | |
| 149 | - | lastSeen: Date.now() | |
| 150 | - | }) | |
| 151 | - | .where(eq(users.id, user.id)); | |
| 146 | + | // Update user status to online using Convex | |
| 147 | + | await users.updateStatus(userId, 'online'); | |
| 152 | 148 | ||
| 153 | 149 | // Set session cookie | |
| 154 | 150 | setSessionTokenCookie({ cookies }, token, session.expiresAt); | |
| 155 | 151 | ||
| 156 | 152 | log.info('Login successful', { | |
| 157 | - | userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`, | |
| 153 | + | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | |
| 158 | 154 | expiresAt: new Date(session.expiresAt).toISOString() | |
| 159 | 155 | }); | |
| 160 | 156 | ||
| 157 | + | // Return user with native Convex ID | |
| 161 | 158 | return new Response( | |
| 162 | 159 | JSON.stringify({ | |
| 163 | 160 | success: true, | |
| 164 | - | user: createSafeUser(user) | |
| 161 | + | user: createSafeUser({ | |
| 162 | + | id: userId, | |
| 163 | + | nickname: user.nickname, | |
| 164 | + | status: 'online', | |
| 165 | + | avatarUrl: user.avatarUrl, | |
| 166 | + | lastSeen: user.lastSeen | |
| 167 | + | }) | |
| 165 | 168 | } as LoginResponseSuccess), | |
| 166 | 169 | { | |
| 167 | 170 | status: 200 | |
M src/routes/api/session/logout/+server.ts +17 -7
| @@ -1,23 +1,33 @@ | |||
| 1 | 1 | import { json } from '@sveltejs/kit'; | |
| 2 | + | import type { RequestHandler } from './$types'; | |
| 2 | 3 | import { invalidateSession } from '$lib/api/session.server'; | |
| 3 | 4 | import { sha256 } from '$lib/api/session.server'; // re-use sha256 helper to compute session id | |
| 4 | 5 | import { createLogger } from '$lib/utils/logger.server'; | |
| 5 | 6 | ||
| 6 | 7 | const log = createLogger('logout-server'); | |
| 7 | 8 | ||
| 8 | - | export async function POST({ cookies }) { | |
| 9 | + | export const POST: RequestHandler = async ({ cookies }) => { | |
| 9 | 10 | const token = cookies.get('session'); | |
| 10 | 11 | ||
| 11 | - | if (token) { | |
| 12 | + | if (!token) { | |
| 13 | + | log.debug('No session token found in cookies'); | |
| 14 | + | return json({ success: true, invalidated: false }); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | try { | |
| 12 | 18 | const sessionId = await sha256(token); | |
| 13 | 19 | await invalidateSession(sessionId); | |
| 14 | 20 | cookies.delete('session', { path: '/' }); | |
| 15 | 21 | log.info('Session invalidated', { | |
| 16 | 22 | sessionId: `${sessionId.slice(0, 4)}...${sessionId.slice(-4)}` | |
| 17 | 23 | }); | |
| 18 | - | } else { | |
| 19 | - | log.debug('No session token found in cookies'); | |
| 24 | + | return json({ success: true, invalidated: true }); | |
| 25 | + | } catch (error) { | |
| 26 | + | log.error('Failed to invalidate session', { | |
| 27 | + | error: error instanceof Error ? error.message : 'Unknown error' | |
| 28 | + | }); | |
| 29 | + | // Still delete cookie to ensure client is logged out | |
| 30 | + | cookies.delete('session', { path: '/' }); | |
| 31 | + | return json({ success: false, error: 'Failed to invalidate session' }, { status: 500 }); | |
| 20 | 32 | } | |
| 21 | - | ||
| 22 | - | return json({ success: true }); | |
| 23 | - | } | |
| 33 | + | }; | |
M src/routes/api/session/validate/+server.ts +6 -5
| @@ -1,10 +1,11 @@ | |||
| 1 | 1 | import { json } from '@sveltejs/kit'; | |
| 2 | + | import type { RequestHandler } from './$types'; | |
| 2 | 3 | import { validateSessionToken } from '$lib/api/session.server'; | |
| 3 | 4 | import { createLogger } from '$lib/utils/logger.server'; | |
| 4 | 5 | ||
| 5 | 6 | const log = createLogger('validate-server'); | |
| 6 | 7 | ||
| 7 | - | export async function GET({ cookies }) { | |
| 8 | + | export const GET: RequestHandler = async ({ cookies }) => { | |
| 8 | 9 | const token = cookies.get('session'); | |
| 9 | 10 | ||
| 10 | 11 | if (!token) { | |
| @@ -19,9 +20,9 @@ export async function GET({ cookies }) { | |||
| 19 | 20 | userId: `${result.user.id.slice(0, 4)}...${result.user.id.slice(-4)}`, | |
| 20 | 21 | sessionId: `${result.session.id.slice(0, 4)}...${result.session.id.slice(-4)}` | |
| 21 | 22 | }); | |
| 22 | - | } else { | |
| 23 | - | log.debug('Invalid session token'); | |
| 23 | + | return json(result, { status: 200 }); | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | - | return json(result); | |
| 27 | - | } | |
| 26 | + | log.debug('Invalid session token'); | |
| 27 | + | return json({ session: null, user: null }, { status: 401 }); | |
| 28 | + | }; | |
D src/routes/api/sse/+server.ts +0 -385
| @@ -1,385 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * SSE Server Endpoint | |
| 3 | - | * | |
| 4 | - | * Handles Server-Sent Events connections with: | |
| 5 | - | * - Event IDs for Last-Event-ID catch-up support | |
| 6 | - | * - Heartbeat mechanism for connection health | |
| 7 | - | * - Proper connection lifecycle management | |
| 8 | - | */ | |
| 9 | - | ||
| 10 | - | declare global { | |
| 11 | - | var __sseIntervalsStarted: boolean; | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | import type { RequestHandler } from './$types'; | |
| 15 | - | import { SSE_CONFIG } from '$lib/sse/config'; | |
| 16 | - | import { sseEmitter, type SSEEvent } from '$lib/sse/SSEEventEmitter'; | |
| 17 | - | import { sseManager } from '$lib/sse/SSEManager'; | |
| 18 | - | import { messageQueue } from '$lib/sse/MessageQueue'; | |
| 19 | - | import db from '$lib/db/db.server'; | |
| 20 | - | import { users } from '$lib/db/schema'; | |
| 21 | - | import { eq, and, lt, ne } from 'drizzle-orm'; | |
| 22 | - | import { createSafeUser } from '$lib/types/chat'; | |
| 23 | - | import { createLogger } from '$lib/utils/logger.server'; | |
| 24 | - | import { buddyListCache } from '$lib/buddyListCache'; | |
| 25 | - | import { env } from '$env/dynamic/public'; | |
| 26 | - | ||
| 27 | - | const log = createLogger('sse-server'); | |
| 28 | - | ||
| 29 | - | console.log('[SSE] Server initialized with new SSE modules'); | |
| 30 | - | ||
| 31 | - | // Default chat room ID | |
| 32 | - | const DEFAULT_CHAT_ROOM_ID = | |
| 33 | - | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 34 | - | ||
| 35 | - | // Intervals for background tasks | |
| 36 | - | let timeoutInterval: ReturnType<typeof setInterval>; | |
| 37 | - | let buddyListInterval: ReturnType<typeof setInterval>; | |
| 38 | - | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 39 | - | ||
| 40 | - | // Timestamp tracker for buddy list updates | |
| 41 | - | let lastBuddyListUpdate = 0; | |
| 42 | - | let lastBuddyListHash = ''; | |
| 43 | - | ||
| 44 | - | /** | |
| 45 | - | * Start periodic check for timed out users | |
| 46 | - | */ | |
| 47 | - | function startTimeoutCheck() { | |
| 48 | - | if (timeoutInterval) { | |
| 49 | - | clearInterval(timeoutInterval); | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | timeoutInterval = setInterval(async () => { | |
| 53 | - | const timeoutThreshold = Date.now() - SSE_CONFIG.CONNECTION_TIMEOUT; | |
| 54 | - | log.debug('Checking for timed out users', { | |
| 55 | - | checkTime: new Date().toISOString(), | |
| 56 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 57 | - | }); | |
| 58 | - | ||
| 59 | - | try { | |
| 60 | - | const now = Date.now(); | |
| 61 | - | const result = await db | |
| 62 | - | .update(users) | |
| 63 | - | .set({ | |
| 64 | - | status: 'offline', | |
| 65 | - | lastSeen: now | |
| 66 | - | }) | |
| 67 | - | .where(and(ne(users.status, 'offline'), lt(users.lastSeen, timeoutThreshold))) | |
| 68 | - | .returning({ | |
| 69 | - | id: users.id, | |
| 70 | - | nickname: users.nickname, | |
| 71 | - | lastSeen: users.lastSeen | |
| 72 | - | }); | |
| 73 | - | ||
| 74 | - | if (result.length > 0) { | |
| 75 | - | log.info('User status updated to offline due to timeout', { | |
| 76 | - | usersUpdated: result.length, | |
| 77 | - | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 78 | - | }); | |
| 79 | - | ||
| 80 | - | buddyListCache.invalidate(); | |
| 81 | - | ||
| 82 | - | // Broadcast status updates for each timed out user | |
| 83 | - | for (const user of result) { | |
| 84 | - | sseEmitter.emit('userStatusUpdate', { | |
| 85 | - | userId: user.id, | |
| 86 | - | status: 'offline', | |
| 87 | - | lastSeen: now | |
| 88 | - | }); | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | } catch (error) { | |
| 92 | - | log.error('Error during timeout check', { error }); | |
| 93 | - | } | |
| 94 | - | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /** | |
| 98 | - | * Start buddy list broadcast interval | |
| 99 | - | */ | |
| 100 | - | function startBuddyListUpdateInterval() { | |
| 101 | - | if (buddyListInterval) { | |
| 102 | - | clearInterval(buddyListInterval); | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | buddyListInterval = setInterval(async () => { | |
| 106 | - | try { | |
| 107 | - | const now = Date.now(); | |
| 108 | - | if (now - lastBuddyListUpdate >= SSE_CONFIG.BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 109 | - | if (buddyListCache.needsRefresh()) { | |
| 110 | - | log.debug('Fetching buddy list from database (cache miss)...'); | |
| 111 | - | const buddyList = await db.select().from(users); | |
| 112 | - | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | |
| 113 | - | ||
| 114 | - | const hasChanged = buddyListCache.update(safeBuddyList); | |
| 115 | - | ||
| 116 | - | if (hasChanged) { | |
| 117 | - | log.debug('Broadcasting buddy list update (data changed)...'); | |
| 118 | - | sseEmitter.emit('buddyListUpdate', safeBuddyList); | |
| 119 | - | } | |
| 120 | - | } else { | |
| 121 | - | const cachedData = buddyListCache.get(); | |
| 122 | - | if (cachedData) { | |
| 123 | - | const currentHash = JSON.stringify(cachedData); | |
| 124 | - | if (currentHash !== lastBuddyListHash) { | |
| 125 | - | log.debug('Broadcasting buddy list update from cache...'); | |
| 126 | - | lastBuddyListHash = currentHash; | |
| 127 | - | sseEmitter.emit('buddyListUpdate', cachedData); | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | lastBuddyListUpdate = now; | |
| 132 | - | } | |
| 133 | - | } catch (error) { | |
| 134 | - | log.error('Error fetching buddy list for update', { error }); | |
| 135 | - | } | |
| 136 | - | }, 1000); | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | /** | |
| 140 | - | * Start periodic cleanup for stale connections | |
| 141 | - | */ | |
| 142 | - | function startConnectionCleanup() { | |
| 143 | - | if (connectionCleanupInterval) { | |
| 144 | - | clearInterval(connectionCleanupInterval); | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | connectionCleanupInterval = setInterval(async () => { | |
| 148 | - | // Get stats and log them periodically | |
| 149 | - | const stats = sseManager.getStats(); | |
| 150 | - | if (stats.totalConnections > 0) { | |
| 151 | - | log.debug('SSE connection stats', stats); | |
| 152 | - | } | |
| 153 | - | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // Start background tasks only once | |
| 157 | - | if (!globalThis.__sseIntervalsStarted) { | |
| 158 | - | startTimeoutCheck(); | |
| 159 | - | startBuddyListUpdateInterval(); | |
| 160 | - | startConnectionCleanup(); | |
| 161 | - | sseManager.startBackgroundTasks(); | |
| 162 | - | globalThis.__sseIntervalsStarted = true; | |
| 163 | - | log.info('Started global intervals for SSE management'); | |
| 164 | - | } else { | |
| 165 | - | log.debug('Global intervals already started, skipping initialization'); | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Cleanup on process exit | |
| 169 | - | if (typeof process !== 'undefined') { | |
| 170 | - | const cleanup = () => { | |
| 171 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 172 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 173 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 174 | - | sseManager.stopBackgroundTasks(); | |
| 175 | - | }; | |
| 176 | - | ||
| 177 | - | process.on('beforeExit', cleanup); | |
| 178 | - | process.on('SIGTERM', () => { | |
| 179 | - | cleanup(); | |
| 180 | - | process.exit(0); | |
| 181 | - | }); | |
| 182 | - | process.on('SIGINT', () => { | |
| 183 | - | cleanup(); | |
| 184 | - | process.exit(0); | |
| 185 | - | }); | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | /** | |
| 189 | - | * Format an SSE event with ID and retry field | |
| 190 | - | */ | |
| 191 | - | function formatSSEEvent(event: SSEEvent, eventId: string): string { | |
| 192 | - | const lines = [ | |
| 193 | - | `id: ${eventId}`, | |
| 194 | - | `event: ${event.type}`, | |
| 195 | - | `data: ${JSON.stringify(event.data)}`, | |
| 196 | - | `retry: ${SSE_CONFIG.RETRY_INTERVAL}`, | |
| 197 | - | '', | |
| 198 | - | '' | |
| 199 | - | ]; | |
| 200 | - | return lines.join('\n'); | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | /** | |
| 204 | - | * GET /api/sse | |
| 205 | - | * | |
| 206 | - | * Establishes an SSE connection for real-time updates. | |
| 207 | - | */ | |
| 208 | - | export const GET: RequestHandler = async ({ request, locals }) => { | |
| 209 | - | log.debug('Processing SSE request', { | |
| 210 | - | url: request.url, | |
| 211 | - | userId: locals.user?.id | |
| 212 | - | }); | |
| 213 | - | ||
| 214 | - | if (!locals.user) { | |
| 215 | - | log.warn('Authentication failed - no user in locals'); | |
| 216 | - | return new Response('Unauthorized', { | |
| 217 | - | status: 401, | |
| 218 | - | headers: { 'Content-Type': 'application/json' } | |
| 219 | - | }); | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | const userId = locals.user.id; | |
| 223 | - | const roomId = DEFAULT_CHAT_ROOM_ID; | |
| 224 | - | const encoder = new TextEncoder(); | |
| 225 | - | ||
| 226 | - | // Check for Last-Event-ID header for catch-up | |
| 227 | - | const lastEventId = request.headers.get('Last-Event-ID'); | |
| 228 | - | if (lastEventId) { | |
| 229 | - | log.debug('Client reconnecting with Last-Event-ID', { | |
| 230 | - | userId: userId.slice(0, 8), | |
| 231 | - | lastEventId: lastEventId.slice(0, 8) | |
| 232 | - | }); | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | let connectionId: string; | |
| 236 | - | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 237 | - | let cleanupListener: (() => void) | undefined; | |
| 238 | - | ||
| 239 | - | const stream = new ReadableStream({ | |
| 240 | - | async start(controller) { | |
| 241 | - | // Add connection to manager | |
| 242 | - | const { connectionId: connId, isNewUser } = sseManager.addConnection( | |
| 243 | - | userId, | |
| 244 | - | roomId, | |
| 245 | - | controller | |
| 246 | - | ); | |
| 247 | - | connectionId = connId; | |
| 248 | - | ||
| 249 | - | // Update user status if new connection | |
| 250 | - | if (isNewUser) { | |
| 251 | - | const now = Date.now(); | |
| 252 | - | try { | |
| 253 | - | await db | |
| 254 | - | .update(users) | |
| 255 | - | .set({ status: 'online', lastSeen: now }) | |
| 256 | - | .where(eq(users.id, userId)); | |
| 257 | - | ||
| 258 | - | log.info('User status updated to online', { | |
| 259 | - | userId: userId.slice(0, 8), | |
| 260 | - | connectionId: connectionId.slice(0, 8) | |
| 261 | - | }); | |
| 262 | - | ||
| 263 | - | buddyListCache.invalidate(); | |
| 264 | - | ||
| 265 | - | sseEmitter.emit('userStatusUpdate', { | |
| 266 | - | userId, | |
| 267 | - | status: 'online', | |
| 268 | - | lastSeen: now | |
| 269 | - | }); | |
| 270 | - | } catch (error) { | |
| 271 | - | log.error('Error updating user status', { error }); | |
| 272 | - | } | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | // Send initial connection event | |
| 276 | - | const connectEvent = formatSSEEvent( | |
| 277 | - | { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } }, | |
| 278 | - | crypto.randomUUID() | |
| 279 | - | ); | |
| 280 | - | controller.enqueue(encoder.encode(connectEvent)); | |
| 281 | - | ||
| 282 | - | // Send catch-up messages if reconnecting with Last-Event-ID | |
| 283 | - | if (lastEventId) { | |
| 284 | - | const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId); | |
| 285 | - | if (missedMessages.length > 0) { | |
| 286 | - | log.info('Sending catch-up messages', { | |
| 287 | - | userId: userId.slice(0, 8), | |
| 288 | - | count: missedMessages.length | |
| 289 | - | }); | |
| 290 | - | for (const msg of missedMessages) { | |
| 291 | - | // Cast to SSEEvent - the data was already validated when queued | |
| 292 | - | const event = { type: msg.event, data: msg.data } as SSEEvent; | |
| 293 | - | const payload = formatSSEEvent(event, msg.id); | |
| 294 | - | controller.enqueue(encoder.encode(payload)); | |
| 295 | - | } | |
| 296 | - | } | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | // Listen for SSE events | |
| 300 | - | const onSSE = (event: SSEEvent) => { | |
| 301 | - | try { | |
| 302 | - | const eventId = crypto.randomUUID(); | |
| 303 | - | const payload = formatSSEEvent(event, eventId); | |
| 304 | - | controller.enqueue(encoder.encode(payload)); | |
| 305 | - | ||
| 306 | - | // Queue message for catch-up (if not heartbeat) | |
| 307 | - | if (event.type !== 'heartbeat') { | |
| 308 | - | messageQueue.enqueue(roomId, event.type, event.data); | |
| 309 | - | } | |
| 310 | - | } catch (error) { | |
| 311 | - | log.error('Error sending event', { error }); | |
| 312 | - | } | |
| 313 | - | }; | |
| 314 | - | ||
| 315 | - | cleanupListener = sseEmitter.addListener('sse', onSSE); | |
| 316 | - | ||
| 317 | - | // Handle abort signal | |
| 318 | - | request.signal.addEventListener('abort', () => { | |
| 319 | - | if (cleanupListener) { | |
| 320 | - | cleanupListener(); | |
| 321 | - | log.info('Removed listener due to abort signal'); | |
| 322 | - | } | |
| 323 | - | }); | |
| 324 | - | ||
| 325 | - | log.info('SSE connection established', { | |
| 326 | - | connectionId: connectionId.slice(0, 8), | |
| 327 | - | userId: userId.slice(0, 8) | |
| 328 | - | }); | |
| 329 | - | ||
| 330 | - | // Keep-alive pings (in addition to heartbeats from manager) | |
| 331 | - | keepAliveInterval = setInterval(() => { | |
| 332 | - | try { | |
| 333 | - | controller.enqueue(encoder.encode(':\n\n')); | |
| 334 | - | } catch { | |
| 335 | - | // Connection might be closed | |
| 336 | - | } | |
| 337 | - | }, SSE_CONFIG.HEARTBEAT_INTERVAL); | |
| 338 | - | }, | |
| 339 | - | ||
| 340 | - | async cancel(reason) { | |
| 341 | - | if (keepAliveInterval) clearInterval(keepAliveInterval); | |
| 342 | - | if (cleanupListener) cleanupListener(); | |
| 343 | - | ||
| 344 | - | const maskedUserId = userId.slice(0, 8); | |
| 345 | - | log.info('Connection closed', { | |
| 346 | - | userId: maskedUserId, | |
| 347 | - | connectionId: connectionId?.slice(0, 8), | |
| 348 | - | reason | |
| 349 | - | }); | |
| 350 | - | ||
| 351 | - | // Remove connection from manager | |
| 352 | - | const { isLastConnection } = sseManager.removeConnection(connectionId); | |
| 353 | - | ||
| 354 | - | // Update database if last connection for this user | |
| 355 | - | if (isLastConnection) { | |
| 356 | - | const now = Date.now(); | |
| 357 | - | try { | |
| 358 | - | await db | |
| 359 | - | .update(users) | |
| 360 | - | .set({ status: 'offline', lastSeen: now }) | |
| 361 | - | .where(eq(users.id, userId)); | |
| 362 | - | ||
| 363 | - | buddyListCache.invalidate(); | |
| 364 | - | ||
| 365 | - | sseEmitter.emit('userStatusUpdate', { | |
| 366 | - | userId, | |
| 367 | - | status: 'offline', | |
| 368 | - | lastSeen: now | |
| 369 | - | }); | |
| 370 | - | } catch (error) { | |
| 371 | - | log.error('Error updating user status on disconnect', { error }); | |
| 372 | - | } | |
| 373 | - | } | |
| 374 | - | } | |
| 375 | - | }); | |
| 376 | - | ||
| 377 | - | return new Response(stream, { | |
| 378 | - | headers: { | |
| 379 | - | 'Content-Type': 'text/event-stream', | |
| 380 | - | 'Cache-Control': 'no-cache', | |
| 381 | - | Connection: 'keep-alive', | |
| 382 | - | 'X-Accel-Buffering': 'no' // Disable nginx buffering | |
| 383 | - | } | |
| 384 | - | }); | |
| 385 | - | }; | |
D src/routes/api/sse/heartbeat/+server.ts +0 -66
| @@ -1,66 +0,0 @@ | |||
| 1 | - | /** | |
| 2 | - | * Heartbeat Acknowledgment Endpoint | |
| 3 | - | * | |
| 4 | - | * Receives heartbeat acknowledgments from clients to track | |
| 5 | - | * active connections and detect stale ones. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | import type { RequestHandler } from './$types'; | |
| 9 | - | import { sseManager } from '$lib/sse/SSEManager'; | |
| 10 | - | import { createLogger } from '$lib/utils/logger.server'; | |
| 11 | - | import { json } from '@sveltejs/kit'; | |
| 12 | - | ||
| 13 | - | const log = createLogger('sse-heartbeat'); | |
| 14 | - | ||
| 15 | - | /** | |
| 16 | - | * POST /api/sse/heartbeat | |
| 17 | - | * | |
| 18 | - | * Acknowledges a heartbeat from a client, updating the connection's | |
| 19 | - | * last heartbeat timestamp. | |
| 20 | - | */ | |
| 21 | - | export const POST: RequestHandler = async ({ request, locals }) => { | |
| 22 | - | // Require authentication | |
| 23 | - | if (!locals.user) { | |
| 24 | - | return json({ success: false, error: 'Unauthorized' }, { status: 401 }); | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | try { | |
| 28 | - | const body = await request.json(); | |
| 29 | - | const { connectionId } = body; | |
| 30 | - | ||
| 31 | - | if (!connectionId || typeof connectionId !== 'string') { | |
| 32 | - | return json({ success: false, error: 'Invalid connectionId' }, { status: 400 }); | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Verify the connection belongs to this user | |
| 36 | - | const connection = sseManager.getConnection(connectionId); | |
| 37 | - | if (!connection) { | |
| 38 | - | log.debug('Heartbeat for unknown connection', { | |
| 39 | - | connectionId: connectionId.slice(0, 8), | |
| 40 | - | userId: locals.user.id.slice(0, 8) | |
| 41 | - | }); | |
| 42 | - | return json({ success: false, error: 'Connection not found' }, { status: 404 }); | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | if (connection.userId !== locals.user.id) { | |
| 46 | - | log.warn('Heartbeat userId mismatch', { | |
| 47 | - | connectionId: connectionId.slice(0, 8), | |
| 48 | - | expectedUserId: connection.userId.slice(0, 8), | |
| 49 | - | actualUserId: locals.user.id.slice(0, 8) | |
| 50 | - | }); | |
| 51 | - | return json({ success: false, error: 'Unauthorized' }, { status: 403 }); | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Acknowledge the heartbeat | |
| 55 | - | const acknowledged = sseManager.acknowledgeHeartbeat(connectionId); | |
| 56 | - | ||
| 57 | - | if (acknowledged) { | |
| 58 | - | return json({ success: true }); | |
| 59 | - | } else { | |
| 60 | - | return json({ success: false, error: 'Failed to acknowledge' }, { status: 500 }); | |
| 61 | - | } | |
| 62 | - | } catch (error) { | |
| 63 | - | log.error('Heartbeat error', { error }); | |
| 64 | - | return json({ success: false, error: 'Internal server error' }, { status: 500 }); | |
| 65 | - | } | |
| 66 | - | }; | |
M src/routes/api/status/+server.ts +38 -35
| @@ -1,11 +1,8 @@ | |||
| 1 | - | import db from '$lib/db/db.server'; | |
| 2 | - | import { users } from '$lib/db/schema'; | |
| 3 | - | import { eq } from 'drizzle-orm/sql'; | |
| 1 | + | import { users } from '$lib/db/convex.server'; | |
| 4 | 2 | import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server'; | |
| 5 | 3 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 6 | 4 | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | 5 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | - | import { buddyListCache } from '$lib/buddyListCache'; | |
| 9 | 6 | import type { RequestHandler } from './$types'; | |
| 10 | 7 | ||
| 11 | 8 | const log = createLogger('status-server'); | |
| @@ -36,47 +33,49 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => { | |||
| 36 | 33 | ||
| 37 | 34 | const now = Date.now(); | |
| 38 | 35 | ||
| 39 | - | // Update the user status in the database | |
| 40 | - | // Always update lastSeen to support timeout detection | |
| 41 | - | const updatedUser = await db | |
| 42 | - | .update(users) | |
| 43 | - | .set({ | |
| 44 | - | status, | |
| 45 | - | lastSeen: now // Always update lastSeen for proper timeout detection | |
| 46 | - | }) | |
| 47 | - | .where(eq(users.id, userId)) | |
| 48 | - | .returning() | |
| 49 | - | .get(); | |
| 50 | - | log.debug('Database updated successfully', { userId: maskedUserId, status }); | |
| 51 | - | ||
| 52 | - | // Invalidate buddy list cache when status changes | |
| 53 | - | buddyListCache.invalidate(); | |
| 36 | + | // Update the user status in Convex | |
| 37 | + | const updatedUser = await users.updateStatus(userId, status); | |
| 38 | + | log.debug('Convex updated successfully', { userId: maskedUserId, status }); | |
| 54 | 39 | ||
| 55 | 40 | // Renew session if status is online and session expires in less than 1 day | |
| 56 | 41 | if (status === 'online') { | |
| 57 | - | const token = cookies.get('session'); | |
| 58 | - | if (token) { | |
| 59 | - | const result = await validateSessionToken(token); | |
| 60 | - | if (result.session) { | |
| 61 | - | const remaining = result.session.expiresAt - now; | |
| 62 | - | const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds | |
| 63 | - | if (remaining < threshold) { | |
| 64 | - | const newToken = generateSessionToken(); | |
| 65 | - | const newSession = await createSession(newToken, userId); | |
| 66 | - | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 67 | - | log.info('Session renewed', { | |
| 68 | - | userId: maskedUserId, | |
| 69 | - | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 70 | - | }); | |
| 42 | + | try { | |
| 43 | + | const token = cookies.get('session'); | |
| 44 | + | if (token) { | |
| 45 | + | const result = await validateSessionToken(token); | |
| 46 | + | if (result.session) { | |
| 47 | + | const remaining = result.session.expiresAt - now; | |
| 48 | + | const threshold = 24 * 60 * 60 * 1000; // 1 day in milliseconds | |
| 49 | + | if (remaining < threshold) { | |
| 50 | + | const newToken = generateSessionToken(); | |
| 51 | + | const newSession = await createSession(newToken, userId); | |
| 52 | + | setSessionTokenCookie({ cookies }, newToken, newSession.expiresAt); | |
| 53 | + | log.info('Session renewed', { | |
| 54 | + | userId: maskedUserId, | |
| 55 | + | expiresAt: new Date(newSession.expiresAt).toISOString() | |
| 56 | + | }); | |
| 57 | + | } | |
| 71 | 58 | } | |
| 72 | 59 | } | |
| 60 | + | } catch (renewalError) { | |
| 61 | + | log.error('Session renewal failed', { | |
| 62 | + | error: renewalError instanceof Error ? renewalError.message : 'Unknown error', | |
| 63 | + | userId: maskedUserId | |
| 64 | + | }); | |
| 65 | + | // Continue with status update even if renewal fails | |
| 73 | 66 | } | |
| 74 | 67 | } | |
| 75 | 68 | ||
| 76 | 69 | return new Response( | |
| 77 | 70 | JSON.stringify({ | |
| 78 | 71 | success: true, | |
| 79 | - | user: createSafeUser(updatedUser) | |
| 72 | + | user: createSafeUser({ | |
| 73 | + | id: updatedUser._id, | |
| 74 | + | nickname: updatedUser.nickname, | |
| 75 | + | status: updatedUser.status, | |
| 76 | + | avatarUrl: updatedUser.avatarUrl, | |
| 77 | + | lastSeen: updatedUser.lastSeen | |
| 78 | + | }) | |
| 80 | 79 | }), | |
| 81 | 80 | { | |
| 82 | 81 | status: 200, | |
| @@ -84,10 +83,14 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => { | |||
| 84 | 83 | } | |
| 85 | 84 | ); | |
| 86 | 85 | } catch (error) { | |
| 86 | + | log.error('Error updating status', { | |
| 87 | + | error: error instanceof Error ? error.message : 'Unknown error', | |
| 88 | + | userId: locals.user?.id | |
| 89 | + | }); | |
| 87 | 90 | return new Response( | |
| 88 | 91 | JSON.stringify({ | |
| 89 | 92 | success: false, | |
| 90 | - | error: error instanceof Error ? error.message : 'Unknown error' | |
| 93 | + | error: 'Failed to update status' | |
| 91 | 94 | }), | |
| 92 | 95 | { | |
| 93 | 96 | status: 500, | |
A src/routes/api/user/text-preferences/+server.ts +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | import { json, error } from '@sveltejs/kit'; | |
| 2 | + | import type { RequestHandler } from './$types'; | |
| 3 | + | import convex from '$lib/db/convex.server'; | |
| 4 | + | import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting'; | |
| 5 | + | import { validateSessionToken } from '$lib/api/session.server'; | |
| 6 | + | ||
| 7 | + | const isKitHttpError = (err: unknown): err is { status: number; body: unknown } => { | |
| 8 | + | return typeof err === 'object' && err !== null && 'status' in err && 'body' in err; | |
| 9 | + | }; | |
| 10 | + | ||
| 11 | + | // GET - Fetch current user's text preferences | |
| 12 | + | export const GET: RequestHandler = async ({ cookies }) => { | |
| 13 | + | try { | |
| 14 | + | // Get session token from cookie | |
| 15 | + | const sessionToken = cookies.get('session'); | |
| 16 | + | if (!sessionToken) { | |
| 17 | + | throw error(401, 'No session found'); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Validate session token | |
| 21 | + | const { user } = await validateSessionToken(sessionToken); | |
| 22 | + | ||
| 23 | + | if (!user) { | |
| 24 | + | throw error(401, 'Invalid session'); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // Get user's text preferences | |
| 28 | + | const preferences = await convex.textPreferences.get(user.id); | |
| 29 | + | ||
| 30 | + | if (!preferences) { | |
| 31 | + | // Return defaults if no preferences exist | |
| 32 | + | return json({ | |
| 33 | + | success: true, | |
| 34 | + | preferences: DEFAULT_USER_PREFERENCES | |
| 35 | + | }); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Convert Convex format to frontend format | |
| 39 | + | const frontendPreferences: UserTextPreferences = { | |
| 40 | + | defaultStyle: preferences.defaultStyle as UserTextPreferences['defaultStyle'], | |
| 41 | + | allowFormatting: preferences.allowFormatting, | |
| 42 | + | maxMessageLength: preferences.maxMessageLength | |
| 43 | + | }; | |
| 44 | + | ||
| 45 | + | return json({ | |
| 46 | + | success: true, | |
| 47 | + | preferences: frontendPreferences | |
| 48 | + | }); | |
| 49 | + | } catch (err) { | |
| 50 | + | console.error('Failed to fetch text preferences:', err); | |
| 51 | + | if (err instanceof Response || isKitHttpError(err)) { | |
| 52 | + | throw err; | |
| 53 | + | } | |
| 54 | + | throw error(500, 'Failed to fetch preferences'); | |
| 55 | + | } | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | // POST - Save current user's text preferences | |
| 59 | + | export const POST: RequestHandler = async ({ cookies, request }) => { | |
| 60 | + | try { | |
| 61 | + | // Get session token from cookie | |
| 62 | + | const sessionToken = cookies.get('session'); | |
| 63 | + | if (!sessionToken) { | |
| 64 | + | throw error(401, 'No session found'); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Validate session token | |
| 68 | + | const { user } = await validateSessionToken(sessionToken); | |
| 69 | + | ||
| 70 | + | if (!user) { | |
| 71 | + | throw error(401, 'Invalid session'); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Parse request body | |
| 75 | + | const body = await request.json(); | |
| 76 | + | const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences; | |
| 77 | + | ||
| 78 | + | // Validate required fields | |
| 79 | + | if ( | |
| 80 | + | !defaultStyle || | |
| 81 | + | typeof allowFormatting !== 'boolean' || | |
| 82 | + | typeof maxMessageLength !== 'number' | |
| 83 | + | ) { | |
| 84 | + | throw error(400, 'Invalid preferences data'); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Save preferences via Convex | |
| 88 | + | const result = await convex.textPreferences.save( | |
| 89 | + | user.id, | |
| 90 | + | defaultStyle, | |
| 91 | + | allowFormatting, | |
| 92 | + | maxMessageLength | |
| 93 | + | ); | |
| 94 | + | ||
| 95 | + | return json({ | |
| 96 | + | success: true, | |
| 97 | + | id: result.id | |
| 98 | + | }); | |
| 99 | + | } catch (err) { | |
| 100 | + | console.error('Failed to save text preferences:', err); | |
| 101 | + | if (err instanceof Response || isKitHttpError(err)) { | |
| 102 | + | throw err; | |
| 103 | + | } | |
| 104 | + | throw error(500, 'Failed to save preferences'); | |
| 105 | + | } | |
| 106 | + | }; | |
M src/routes/api/users/[userId]/+server.ts +9 -5
| @@ -1,7 +1,5 @@ | |||
| 1 | 1 | import { error, json } from '@sveltejs/kit'; | |
| 2 | - | import db from '$lib/db/db.server'; | |
| 3 | - | import { users } from '$lib/db/schema'; | |
| 4 | - | import { eq } from 'drizzle-orm'; | |
| 2 | + | import { users } from '$lib/db/convex.server'; | |
| 5 | 3 | import type { RequestHandler } from './$types'; | |
| 6 | 4 | import { createSafeUser } from '$lib/types/chat'; | |
| 7 | 5 | import { createLogger } from '$lib/utils/logger.server'; | |
| @@ -13,14 +11,20 @@ export const GET: RequestHandler = async ({ params }) => { | |||
| 13 | 11 | log.debug('Fetching user data', { userId: maskedUserId }); | |
| 14 | 12 | ||
| 15 | 13 | try { | |
| 16 | - | const user = await db.select().from(users).where(eq(users.id, params.userId)).get(); | |
| 14 | + | const user = await users.getById(params.userId); | |
| 17 | 15 | ||
| 18 | 16 | if (!user) { | |
| 19 | 17 | log.warn('User not found', { userId: maskedUserId }); | |
| 20 | 18 | throw error(404, 'User not found'); | |
| 21 | 19 | } | |
| 22 | 20 | ||
| 23 | - | const safeUser = createSafeUser(user); | |
| 21 | + | const safeUser = createSafeUser({ | |
| 22 | + | id: user._id, | |
| 23 | + | nickname: user.nickname, | |
| 24 | + | status: user.status, | |
| 25 | + | avatarUrl: user.avatarUrl, | |
| 26 | + | lastSeen: user.lastSeen | |
| 27 | + | }); | |
| 24 | 28 | ||
| 25 | 29 | log.debug('User data retrieved', { | |
| 26 | 30 | userId: maskedUserId, | |
M svelte.config.js +6 -1
| @@ -19,7 +19,12 @@ const config = { | |||
| 19 | 19 | 'script-src': ['self', 'https://challenges.cloudflare.com'], | |
| 20 | 20 | 'style-src': ['self', 'unsafe-inline'], | |
| 21 | 21 | 'img-src': ['self', 'data:', 'blob:'], | |
| 22 | - | 'connect-src': ['self', 'https://challenges.cloudflare.com'], | |
| 22 | + | 'connect-src': [ | |
| 23 | + | 'self', | |
| 24 | + | 'https://challenges.cloudflare.com', | |
| 25 | + | 'https://*.convex.cloud', | |
| 26 | + | 'wss://*.convex.cloud' | |
| 27 | + | ], | |
| 23 | 28 | 'font-src': ['self'], | |
| 24 | 29 | 'frame-src': ['https://challenges.cloudflare.com'], | |
| 25 | 30 | 'frame-ancestors': ['none'], | |
D utils/db-flush.ts +0 -57
| @@ -1,57 +0,0 @@ | |||
| 1 | - | import { createClient } from '@libsql/client'; | |
| 2 | - | import * as dotenv from 'dotenv'; | |
| 3 | - | ||
| 4 | - | // Load environment variables from .env file | |
| 5 | - | dotenv.config(); | |
| 6 | - | ||
| 7 | - | const { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } = process.env; | |
| 8 | - | ||
| 9 | - | if (!TURSO_DATABASE_URL || !TURSO_AUTH_TOKEN) { | |
| 10 | - | throw new Error( | |
| 11 | - | 'Missing required environment variables TURSO_DATABASE_URL and/or TURSO_AUTH_TOKEN' | |
| 12 | - | ); | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | async function flushDatabase() { | |
| 16 | - | console.log('Starting database content flush...'); | |
| 17 | - | ||
| 18 | - | const client = createClient({ | |
| 19 | - | url: TURSO_DATABASE_URL as string, | |
| 20 | - | authToken: TURSO_AUTH_TOKEN as string | |
| 21 | - | }); | |
| 22 | - | ||
| 23 | - | try { | |
| 24 | - | // Clear tables in correct order to respect foreign key constraints | |
| 25 | - | console.log('Clearing messages table...'); | |
| 26 | - | await client.execute('DELETE FROM messages'); | |
| 27 | - | ||
| 28 | - | console.log('Clearing sessions table...'); | |
| 29 | - | await client.execute('DELETE FROM sessions'); | |
| 30 | - | ||
| 31 | - | console.log('Clearing chat rooms table...'); | |
| 32 | - | await client.execute('DELETE FROM chat_rooms'); | |
| 33 | - | ||
| 34 | - | console.log('Clearing users table...'); | |
| 35 | - | await client.execute('DELETE FROM users'); | |
| 36 | - | ||
| 37 | - | console.log('Database content flush completed successfully'); | |
| 38 | - | } catch (error) { | |
| 39 | - | console.error('Error flushing database content:', error); | |
| 40 | - | throw error; | |
| 41 | - | } finally { | |
| 42 | - | await client.close(); | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // Execute if this file is run directly | |
| 47 | - | if (new URL(import.meta.url).pathname === process.argv[1]) { | |
| 48 | - | flushDatabase() | |
| 49 | - | .then(() => { | |
| 50 | - | console.debug('[db-flush] Exiting process with code 0'); | |
| 51 | - | process.exit(0); | |
| 52 | - | }) | |
| 53 | - | .catch((error) => { | |
| 54 | - | console.error('[db-flush] Failed to flush database content:', error); | |
| 55 | - | process.exit(1); | |
| 56 | - | }); | |
| 57 | - | } | |