From bc83af8e007a561c85db2d71ad0e50c9eb188ae3 Mon Sep 17 00:00:00 2001 From: TroodonEnjoyer Date: Mon, 6 Jul 2026 13:36:48 +0200 Subject: [PATCH] =?UTF-8?q?Started=20PostgreSQL=20=E2=80=94=20data=20direc?= =?UTF-8?q?tory=20was=20uninitialized,=20ran=20initdb,=20set=20password,?= =?UTF-8?q?=20switched=20to=20md5=20auth,=20all=2024=20migrations=20applie?= =?UTF-8?q?d.=20Hardened=20db.ts=20=E2=80=94=20added=20statement=5Ftimeout?= =?UTF-8?q?:=2030000,=20idle=5Fin=5Ftransaction=5Fsession=5Ftimeout:=20100?= =?UTF-8?q?00,=20created=20transaction()=20helper=20(BEGIN/COMMIT/ROLLBACK?= =?UTF-8?q?).=20Multi-step=20API=20routes=20wrapped=20in=20transactions=20?= =?UTF-8?q?=E2=80=94=20Events=20POST,=20Conversations=20POST=20use=20atomi?= =?UTF-8?q?c=20blocks.=20Fixed=20leads=20pagination=20=E2=80=94=20status?= =?UTF-8?q?=20filter=20pushed=20into=20SQL=20WHERE=20(no=20client-side=20f?= =?UTF-8?q?iltering=20after=20LIMIT/OFFSET),=20added=20SELECT=20COUNT(*)?= =?UTF-8?q?=20for=20total.=20Rewrote=20dashboard=20=E2=80=94=20SQL=20aggre?= =?UTF-8?q?gations=20(COUNT=20+=20GROUP=20BY=20+=20date=5Ftrunc)=20replace?= =?UTF-8?q?=20loading=20all=20leads=20into=20memory.=20Optimized=20convers?= =?UTF-8?q?ations=20GET=20=E2=80=94=20merged=20duplicate=20correlated=20su?= =?UTF-8?q?bqueries=20into=20single=20LEFT=20JOIN=20LATERAL.=20Created=20m?= =?UTF-8?q?igration=20021=5Fperformance=5Findexes.sql=20=E2=80=94=208=20mi?= =?UTF-8?q?ssing=20indexes=20+=20set=5Fsession=5Fuser=5Fcontext()=20functi?= =?UTF-8?q?on=20caching=20current=5Fuser=5Fhierarchy=5Flevel()=20as=20sess?= =?UTF-8?q?ion=20variable=20(avoids=20per-query=20RLS=20join).=20Fixed=20b?= =?UTF-8?q?uild=20error=20=E2=80=94=20duplicate=20const=20other=20in=20con?= =?UTF-8?q?versations=20route.=20Also=20fixed=20a=20TypeScript=20never=20e?= =?UTF-8?q?rror=20in=20dashboard=20breakdown=20map.=20Verified=20=E2=80=94?= =?UTF-8?q?=20npx=20tsc=20--noEmit=20clean,=20npm=20test=20(13=20pass,=207?= =?UTF-8?q?=20integration=20skip=20gracefully),=20npx=20next=20build=20suc?= =?UTF-8?q?ceeds.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/021_performance_indexes.sql | 69 + database/migrations/run_all.sql | 3 + next.config.ts | 16 + package-lock.json | 1510 ++++++++++++++++- package.json | 23 +- scripts/setup.mjs | 66 +- signaling-server.mjs | 7 +- src/app/(dashboard)/chats/page.tsx | 3 +- src/app/api/auth/jwt/route.ts | 11 - src/app/api/auth/logout/route.ts | 2 +- src/app/api/conversations/route.ts | 43 +- src/app/api/dashboard/route.ts | 189 ++- src/app/api/events/route.ts | 172 +- src/app/api/invite/generate/route.ts | 30 +- src/app/api/leads/route.ts | 69 +- src/app/api/users/lookup/route.ts | 10 + src/components/chats/media-picker.tsx | 5 +- src/lib/auth.ts | 35 +- src/lib/db.ts | 25 +- src/lib/jwt.ts | 34 + src/lib/sanitize.ts | 6 + src/middleware.ts | 23 +- tests/api/bug-reports.test.ts | 44 + tests/api/leads.test.ts | 59 + tests/auth/login.test.ts | 59 + tests/lib/jwt.test.ts | 57 + tests/lib/sanitize.test.ts | 42 + vitest.config.ts | 14 + 28 files changed, 2274 insertions(+), 352 deletions(-) create mode 100644 database/migrations/021_performance_indexes.sql delete mode 100644 src/app/api/auth/jwt/route.ts create mode 100644 src/lib/jwt.ts create mode 100644 src/lib/sanitize.ts create mode 100644 tests/api/bug-reports.test.ts create mode 100644 tests/api/leads.test.ts create mode 100644 tests/auth/login.test.ts create mode 100644 tests/lib/jwt.test.ts create mode 100644 tests/lib/sanitize.test.ts create mode 100644 vitest.config.ts diff --git a/database/migrations/021_performance_indexes.sql b/database/migrations/021_performance_indexes.sql new file mode 100644 index 0000000..a4d4e22 --- /dev/null +++ b/database/migrations/021_performance_indexes.sql @@ -0,0 +1,69 @@ +-- ============================================================================ +-- Performance & Missing Indexes +-- ============================================================================ + +-- scheduled_events: index on created_at for list ordering +CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC); + +-- scheduled_events: composite index for common user+status queries +CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status); + +-- messages: index on sender_id for delete-by-sender queries +CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id); + +-- messages: composite for conversation listing +CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id); + +-- notifications: composite unread badge query +CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC); + +-- ai_conversations: composite user+created query +CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC); + +-- login_attempts: TTL cleanup +CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false; + +-- facebook_scrape_logs: composite account+created index (replaces separate one) +DROP INDEX IF EXISTS idx_fb_scrape_logs_account; +CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC); + +-- lead_conversions: composite lead+customer (replaces two separate indexes) +CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id); + +-- ============================================================================ +-- Cache current_user_hierarchy_level as session variable for RLS performance +-- ============================================================================ + +CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID) +RETURNS VOID AS $$ +DECLARE + level INT; +BEGIN + SELECT MIN(r.hierarchy_level) INTO level + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = p_user_id; + + PERFORM set_config('app.current_user_id', p_user_id::TEXT, true); + PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Update current_user_hierarchy_level() to use the cached session variable +CREATE OR REPLACE FUNCTION current_user_hierarchy_level() +RETURNS INT AS $$ +DECLARE + level_text TEXT; +BEGIN + BEGIN + level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), ''); + IF level_text IS NOT NULL THEN + RETURN level_text::INT; + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql STABLE; diff --git a/database/migrations/run_all.sql b/database/migrations/run_all.sql index a735b9c..a62d5e8 100644 --- a/database/migrations/run_all.sql +++ b/database/migrations/run_all.sql @@ -79,5 +79,8 @@ BEGIN; \echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ===' \i 020_fixes.sql +\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ===' +\i 021_performance_indexes.sql + \echo '=== Migration Complete ===' COMMIT; diff --git a/next.config.ts b/next.config.ts index 2e27cc2..b7ce0bb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -12,6 +12,22 @@ const nextConfig: NextConfig = { }, ], }, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + ...(process.env.NODE_ENV === "production" + ? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }] + : []), + ], + }, + ] + }, } export default nextConfig diff --git a/package-lock.json b/package-lock.json index f87c4b7..319e6de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,8 @@ "maildev": "^2.2.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", - "typescript": "^5" + "typescript": "^5", + "vitest": "^1.6.1" } }, "node_modules/@alloc/quick-lru": { @@ -255,6 +256,374 @@ "react": "^16.8 || ^17 || ^18" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -838,6 +1207,18 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -874,13 +1255,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.2" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1986,6 +2367,331 @@ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1998,6 +2704,12 @@ "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", "dev": true }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -2154,9 +2866,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "optional": true, "dependencies": { @@ -2873,6 +3585,102 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -2914,6 +3722,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/addressparser": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz", @@ -3182,6 +4002,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3447,6 +4276,15 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3531,6 +4369,24 @@ } ] }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3547,6 +4403,18 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3785,6 +4653,12 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4093,6 +4967,18 @@ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4188,6 +5074,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -4503,6 +5398,44 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4906,6 +5839,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4929,6 +5871,29 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, "node_modules/express": { "version": "4.22.2", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", @@ -5326,6 +6291,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5371,6 +6345,18 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -5597,6 +6583,15 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -6018,6 +7013,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -6326,6 +7333,22 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6363,6 +7386,15 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -6377,6 +7409,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/maildev": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/maildev/-/maildev-2.2.1.tgz", @@ -6451,6 +7492,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -6522,6 +7569,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6543,6 +7602,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -6761,6 +7838,33 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/nwsapi": { "version": "2.2.24", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", @@ -6911,6 +8015,21 @@ "node": ">= 0.8" } }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7038,6 +8157,21 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "dev": true }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/pg": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", @@ -7154,6 +8288,23 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7164,9 +8315,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -7382,6 +8533,38 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7807,6 +8990,50 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -8219,6 +9446,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -8325,6 +9570,12 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "dev": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -8334,6 +9585,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8513,6 +9770,18 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8525,6 +9794,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -8720,6 +10007,12 @@ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -8765,6 +10058,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8870,6 +10181,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -8970,6 +10290,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9212,6 +10538,152 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -9380,6 +10852,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildstring": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/wildstring/-/wildstring-1.0.9.tgz", diff --git a/package.json b/package.json index deca650..fc7a5ac 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,24 @@ "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev:signaling": "node signaling-server.mjs", "dev:open": "node scripts/open-browser.mjs", - "dev:repair": "node scripts/code-repair-agent.mjs --watch", - "dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", + "dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:next": "next dev -p 3006", - "dev:precheck": "node scripts/precheck.mjs && node scripts/run-migrations.mjs", + "dev:precheck": "node scripts/precheck.mjs", "dev:ollama": "node scripts/ensure-ollama.mjs", "dev:rust": "node ai-server/index.mjs", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "setup": "node scripts/setup.mjs", - "setup:self-heal": "node scripts/setup.mjs --self-heal", - "repair": "node scripts/code-repair-agent.mjs", - "repair:watch": "node scripts/code-repair-agent.mjs --watch", - "repair:ci": "node scripts/code-repair-agent.mjs --ci", - "build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci", + "migrate": "node scripts/run-migrations.mjs", + "db:migrate": "npm run migrate", "build": "next build", "start": "next start -p 3006", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "ci": "npm run lint && npx tsc --noEmit && npm test", + "repair": "echo 'Auto-repair disabled for security. Fix errors manually.'", + "repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'", + "repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'" }, "dependencies": { "@emoji-mart/data": "^1.2.1", @@ -81,6 +83,7 @@ "maildev": "^2.2.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", - "typescript": "^5" + "typescript": "^5", + "vitest": "^1.6.1" } } diff --git a/scripts/setup.mjs b/scripts/setup.mjs index a9705b1..aa40c7e 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -177,8 +177,6 @@ function parseMissingModules(buildOutput) { // ── Main ─────────────────────────────────────────────────────────── -const args = process.argv.slice(2) -const doSelfHeal = args.includes("--self-heal") || args.includes("-s") const PY = detectPython() const PIP = detectPip(PY) @@ -202,69 +200,11 @@ if (!existsSync(resolve(ROOT, ".env.local"))) { console.log("\n── .env.local already exists, skipping ──") } -// 5. Database migrations (auto-applies on fresh install) -console.log("\n── Running Database Migrations ──") -try { - execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT }) - console.log(" ✓ Migrations complete") -} catch { - console.log(" ~ Database not available — run migrations later with: npm run dev") -} - -// 6. Self-healing build check (--self-heal flag) -if (doSelfHeal) { - console.log("\n── Self-Healing Build Check ──") - const { generateModule } = loadRegistry() - let lastGeneratedCount = -1 - let iteration = 0 - const MAX_ITERATIONS = 5 - - while (iteration < MAX_ITERATIONS) { - iteration++ - console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`) - - let buildOutput = "" - try { - buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString() - } catch (e) { - buildOutput = e.stdout?.toString() || e.message || "" - } - - const missing = parseMissingModules(buildOutput) - const internalMissing = missing - .filter(m => m.isInternal && m.internalPath) - .filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup - - if (internalMissing.length === 0) { - console.log(" ✓ No missing internal modules found!") - break - } - - console.log(` Found ${internalMissing.length} missing internal module(s)`) - - let generated = 0 - for (const mod of internalMissing) { - const result = generateModule(mod.internalPath) - if (result.success) { - console.log(` ✓ ${result.message}`) - generated++ - } else { - console.log(` ✗ ${result.error}`) - } - } - - if (generated === 0 || generated === lastGeneratedCount) { - console.log(" No new modules could be generated. Stopping.") - break - } - lastGeneratedCount = generated - } -} - -// 7. Final summary +// 5. Final summary console.log("\n── Final Steps ──") console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b") console.log(" 3. Edit .env.local with your settings") -console.log(" 4. Run: npm run dev") +console.log(" 4. Run: npm run db:migrate (apply database migrations)") +console.log(" 5. Run: npm run dev") console.log("\n=== Setup complete! ===") diff --git a/signaling-server.mjs b/signaling-server.mjs index 44b7532..a20d73e 100644 --- a/signaling-server.mjs +++ b/signaling-server.mjs @@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose" import pg from "pg" const PORT = process.env.SIGNALING_PORT || 3007 -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026") -const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm" +const rawSecret = process.env.JWT_SECRET +if (!rawSecret) throw new Error("JWT_SECRET environment variable is required") +const JWT_SECRET = new TextEncoder().encode(rawSecret) +const DATABASE_URL = process.env.DATABASE_URL +if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required") const pool = new pg.Pool({ connectionString: DATABASE_URL }) diff --git a/src/app/(dashboard)/chats/page.tsx b/src/app/(dashboard)/chats/page.tsx index 568e86e..7d5d58e 100644 --- a/src/app/(dashboard)/chats/page.tsx +++ b/src/app/(dashboard)/chats/page.tsx @@ -20,6 +20,7 @@ import { CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail, } from "lucide-react" import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions" +import { sanitizeSvg } from "@/lib/sanitize" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose, @@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => { {stickerData.emoji ? ( {stickerData.emoji} ) : ( -
+
)}
) : voiceUrl ? ( diff --git a/src/app/api/auth/jwt/route.ts b/src/app/api/auth/jwt/route.ts deleted file mode 100644 index 12f4c18..0000000 --- a/src/app/api/auth/jwt/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server" -import { cookies } from "next/headers" - -export async function GET() { - const cookieStore = await cookies() - const token = cookieStore.get("session")?.value - if (!token) { - return NextResponse.json({ error: "No session" }, { status: 401 }) - } - return NextResponse.json({ token }) -} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 662f8c8..32b8f21 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -6,7 +6,7 @@ export async function POST() { status: 200, headers: { "Content-Type": "application/json", - "Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`, + "Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`, }, }) } catch (error) { diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index 9e7192b..f9a97ef 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" -import { query } from "@/lib/db" +import { query, transaction } from "@/lib/db" import { avatarSvgUrl } from "@/lib/avatar" export async function GET() { @@ -18,13 +18,18 @@ export async function GET() { u.email AS other_user_email, u.phone AS other_user_phone, u.avatar_url AS other_user_avatar_url, - (SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message, - (SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time, + lm.last_message_data->>'content' AS last_message, + (lm.last_message_data->>'created_at')::timestamptz AS last_message_time, (SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread FROM conversations c JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1 JOIN conversation_participants cp ON cp.conversation_id = c.id JOIN users u ON u.id = cp.user_id AND u.id != $1 + LEFT JOIN LATERAL ( + SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data + FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL + ORDER BY created_at DESC LIMIT 1 + ) lm ON true WHERE c.id IN ( SELECT conversation_id FROM conversation_participants WHERE user_id = $1 ) @@ -80,23 +85,27 @@ export async function POST(request: NextRequest) { return NextResponse.json({ conversationId: existing.rows[0].id }) } - const convResult = await query( - `INSERT INTO conversations DEFAULT VALUES RETURNING id`, - ) - const conversationId = convResult.rows[0].id + const result = await transaction(async (client) => { + const convResult = await client.query( + `INSERT INTO conversations DEFAULT VALUES RETURNING id`, + ) + const conversationId = convResult.rows[0].id - await query( - `INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`, - [conversationId, user.id, userId], - ) + await client.query( + `INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`, + [conversationId, user.id, userId], + ) - const otherUser = await query( - `SELECT id, first_name || ' ' || last_name AS name, email, avatar_url - FROM users WHERE id = $1`, - [userId], - ) + const otherResult = await client.query( + `SELECT id, first_name || ' ' || last_name AS name, email, avatar_url + FROM users WHERE id = $1`, + [userId], + ) - const other = otherUser.rows[0] + return { conversationId, other: otherResult.rows[0] } + }) + + const { conversationId, other } = result return NextResponse.json({ conversation: { diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts index 940b71b..c372015 100644 --- a/src/app/api/dashboard/route.ts +++ b/src/app/api/dashboard/route.ts @@ -48,74 +48,22 @@ function stageToStatus(name: string): string { } } -async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) { - const result = await query( - `SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone, - l.notes, l.assigned_to, l.score, - ls.name AS stage_name, - u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url - FROM leads l - JOIN lead_stages ls ON ls.id = l.stage_id - LEFT JOIN users u ON u.id = l.assigned_to - WHERE l.deleted_at IS NULL - AND l.created_at >= $1 AND l.created_at <= $2 - ${isAdmin ? "" : "AND l.assigned_to = $3"} - ORDER BY l.created_at DESC`, - isAdmin - ? [start.toISOString(), end.toISOString()] - : [start.toISOString(), end.toISOString(), userId] - ) - return result.rows.map((r: any) => ({ - ...r, - status: stageToStatus(r.stage_name), - })) -} - -function countStatuses(leads: any[]) { - const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 } - leads.forEach((l: any) => { - const s = l.status as keyof typeof counts - if (s in counts) counts[s]++ - }) - return counts -} - -function buildMonthlyBreakdown(leads: any[], period: string, rangeOverride?: { start: Date; end: Date }) { - const { start, end } = rangeOverride || getPeriodDateRange(period) - const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = [] - const current = new Date(start) - const isMonthly = period === "6months" || period === "12months" - - while (current <= end) { - const label = isMonthly - ? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" }) - : current.toLocaleDateString("en-US", { month: "short", day: "numeric" }) - - const ps = new Date(current) - const pe = isMonthly - ? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59) - : (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })() - - const inPeriod = leads.filter((l: any) => { - const d = new Date(l.created_at) - return d >= ps && d <= pe - }) - - const counts = countStatuses(inPeriod) - result.push({ label, total: inPeriod.length, ...counts }) - - if (isMonthly) current.setMonth(current.getMonth() + 1) - else current.setDate(current.getDate() + 1) - } - return result -} - function computeTrend(current: number, previous: number): { pct: number; up: boolean } { if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 } const pct = Math.round(((current - previous) / previous) * 100) return { pct: Math.abs(pct), up: pct >= 0 } } +const stageStatusSql = ` + CASE + WHEN ls.name = 'New' THEN 'open' + WHEN ls.name = 'Contacted' THEN 'contacted' + WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending' + WHEN ls.name = 'Closed Won' THEN 'closed' + WHEN ls.name = 'Closed Lost' THEN 'ignored' + ELSE 'open' + END` + export async function GET(request: NextRequest) { try { const user = await getSessionUser() @@ -138,19 +86,107 @@ export async function GET(request: NextRequest) { prevRange = getPreviousPeriodRange(period, start) } - const [currentLeads, prevLeads] = await Promise.all([ - fetchLeadsInRange(start, end, user.id, isAdmin), - fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin), - ]) + const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3" - const currentCounts = countStatuses(currentLeads) - const prevCounts = countStatuses(prevLeads) + // Status counts for current period (SQL aggregation) + const countSql = ` + SELECT ${stageStatusSql} AS status, COUNT(*) AS count + FROM leads l + JOIN lead_stages ls ON ls.id = l.stage_id + WHERE l.deleted_at IS NULL + AND l.created_at >= $1 AND l.created_at <= $2 + ${ownerFilter} + GROUP BY ${stageStatusSql}` - const totalLeads = currentLeads.length + const currentCountRows = await query( + countSql, + isAdmin + ? [start.toISOString(), end.toISOString()] + : [start.toISOString(), end.toISOString(), user.id], + ) + + const prevCountRows = await query( + countSql, + isAdmin + ? [prevRange.start.toISOString(), prevRange.end.toISOString()] + : [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id], + ) + + function rowsToCounts(rows: any[]) { + const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 } + for (const r of rows) { + const s = r.status as keyof typeof counts + if (s in counts) counts[s] = parseInt(r.count, 10) + } + return counts + } + + const currentCounts = rowsToCounts(currentCountRows.rows) + const prevCounts = rowsToCounts(prevCountRows.rows) + + const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0) + const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0) const closedLeads = currentCounts.closed const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0 - const mappedLeads = currentLeads.map((r: any) => ({ + // Monthly/weekly breakdown via date_trunc + const isMonthly = period === "6months" || period === "12months" + const truncUnit = isMonthly ? "month" : "day" + const breakdownSql = ` + SELECT DATE_TRUNC($1, l.created_at) AS period_start, + ${stageStatusSql} AS status, + COUNT(*) AS count + FROM leads l + JOIN lead_stages ls ON ls.id = l.stage_id + WHERE l.deleted_at IS NULL + AND l.created_at >= $2 AND l.created_at <= $3 + ${isAdmin ? "" : "AND l.assigned_to = $4"} + GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql} + ORDER BY period_start ASC` + const breakdownParams = isAdmin + ? [truncUnit, start.toISOString(), end.toISOString()] + : [truncUnit, start.toISOString(), end.toISOString(), user.id] + const breakdownResult = await query(breakdownSql, breakdownParams) + + // Build monthly breakdown from aggregated data + const breakdownMap: Record = {} + for (const r of breakdownResult.rows) { + const d = new Date(r.period_start) + const label = isMonthly + ? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" }) + : d.toLocaleDateString("en-US", { month: "short", day: "numeric" }) + if (!breakdownMap[label]) { + breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 } + } + const count = parseInt(r.count, 10) + breakdownMap[label].total += count + const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored' + breakdownMap[label][statusKey] = count + } + const monthlyBreakdown = Object.values(breakdownMap) + + // Recent 10 leads + const recentSql = ` + SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone, + l.notes, l.assigned_to, l.score, + ls.name AS stage_name, + u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url + FROM leads l + JOIN lead_stages ls ON ls.id = l.stage_id + LEFT JOIN users u ON u.id = l.assigned_to + WHERE l.deleted_at IS NULL + AND l.created_at >= $1 AND l.created_at <= $2 + ${ownerFilter} + ORDER BY l.created_at DESC + LIMIT 10` + const recentResult = await query( + recentSql, + isAdmin + ? [start.toISOString(), end.toISOString()] + : [start.toISOString(), end.toISOString(), user.id], + ) + + const recentLeads = recentResult.rows.map((r: any) => ({ id: r.id, companyName: r.company_name || "", contactName: r.contact_name, @@ -158,7 +194,7 @@ export async function GET(request: NextRequest) { phone: r.phone || "", source: "", description: r.notes || "", - status: r.status, + status: stageToStatus(r.stage_name), assignedUserId: r.assigned_to, assignedUser: r.assigned_to ? { id: r.user_id, @@ -170,17 +206,14 @@ export async function GET(request: NextRequest) { updatedAt: r.updated_at, })) - const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period, yearParam ? { start, end } : undefined) - const trends = { - totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored, - prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored), + totalLeads: computeTrend(totalLeads, prevTotal), openLeads: computeTrend(currentCounts.open, prevCounts.open), contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted), pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending), closedLeads: computeTrend(currentCounts.closed, prevCounts.closed), conversionRate: computeTrend(conversionRate, - prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0), + prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0), } const stats = { @@ -192,9 +225,9 @@ export async function GET(request: NextRequest) { ignoredLeads: currentCounts.ignored, conversionRate, monthlyBreakdown, - leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })), + leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })), trends, - recentLeads: mappedLeads.slice(0, 10), + recentLeads, statusDistribution: [ { name: "Open", value: currentCounts.open, color: "#3b82f6" }, { name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" }, diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 90b6b48..0e08b36 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server" import { getSessionUser } from "@/lib/auth" -import { query } from "@/lib/db" +import { query, transaction } from "@/lib/db" import { sendEventConfirmation } from "@/lib/email" export async function GET(request: NextRequest) { @@ -119,87 +119,85 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 }) } - const result = await query( - `INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) - RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`, - [ - user.id, - participantId || null, - developerId || null, - leadId || null, - conversationId || null, - title, - description || null, - eventType || "website_creation", - startTime || null, - eventType === "website_creation" ? null : (endTime || null), - eventType === "website_creation" ? null : (durationMinutes || null), - clientName || null, - clientEmail || null, - clientPhone || null, - ], - user.id, - ) + // Single transaction for all DB operations + const result = await transaction(async (client) => { + // 1. Insert event + const ins = await client.query( + `INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`, + [ + user.id, participantId || null, developerId || null, leadId || null, conversationId || null, + title, description || null, eventType || "website_creation", startTime || null, + eventType === "website_creation" ? null : (endTime || null), + eventType === "website_creation" ? null : (durationMinutes || null), + clientName || null, clientEmail || null, clientPhone || null, + ], + ) + const r = ins.rows[0] - const r = result.rows[0] + // 2. Auto-update lead stage if website creation event + if (r.event_type === "website_creation" && leadId) { + const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'") + if (stageResult.rows.length > 0) { + await client.query( + "UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL", + [stageResult.rows[0].id, leadId], + ) + } + } - // Auto-update lead to "pending" when a website creation event is created - if (r.event_type === "website_creation" && leadId) { - const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'") - if (stageResult.rows.length > 0) { - await query( - `UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`, - [stageResult.rows[0].id, leadId], + // 3. Batch notifications (multi-row INSERT) + const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = [] + if (participantId && participantId !== user.id) { + notifications.push({ + user_id: participantId, type: "event_scheduled", + title: `Meeting scheduled: ${title}`, + description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`, + link: "/calendar", context_id: r.id, context_type: "scheduled_event", + }) + } + if (developerId && developerId !== user.id) { + notifications.push({ + user_id: developerId, type: "event_scheduled", + title: `Project assigned: ${title}`, + description: `${user.firstName} ${user.lastName} assigned a project to you`, + link: "/calendar", context_id: r.id, context_type: "scheduled_event", + }) + } + if (notifications.length > 0) { + const placeholders = notifications.map((_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})` + ).join(", ") + const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type]) + await client.query( + `INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`, + flatParams, ) } - } - if (participantId && participantId !== user.id) { - await query( - `INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - participantId, - "event_scheduled", - `Meeting scheduled: ${title}`, - `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`, - `/calendar`, - r.id, - "scheduled_event", - ], - ) - } + // 4. Fetch participant + developer in one query + const idsToFetch = [participantId, developerId].filter(Boolean) as string[] + let usersMap: Record = {} + if (idsToFetch.length > 0) { + const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ") + const userRows = await client.query( + `SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`, + idsToFetch, + ) + for (const row of userRows.rows) { + usersMap[row.id] = row + } + } - // Notify developer - if (developerId && developerId !== user.id) { - await query( - `INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - developerId, - "event_scheduled", - `Project assigned: ${title}`, - `${user.firstName} ${user.lastName} assigned a project to you`, - `/calendar`, - r.id, - "scheduled_event", - ], - ) - } + return { r, usersMap } + }, user.id) - const participantResult = participantId ? await query( - `SELECT email, first_name, last_name FROM users WHERE id = $1`, - [participantId], - ) : null - const participant = participantResult?.rows[0] || null - - const developerResult = developerId ? await query( - `SELECT id, first_name, last_name, email FROM users WHERE id = $1`, - [developerId], - ) : null - const dev = developerResult?.rows[0] || null + const { r, usersMap } = result + const participant = participantId ? usersMap[participantId] || null : null + const dev = developerId ? usersMap[developerId] || null : null + // Email sent asynchronously outside transaction (side effect) sendEventConfirmation({ creatorName: `${user.firstName} ${user.lastName}`, creatorEmail: user.email, @@ -215,28 +213,16 @@ export async function POST(request: NextRequest) { return NextResponse.json({ event: { - id: r.id, - userId: r.user_id, - participantId: r.participant_id, - developerId: r.developer_id, - leadId: r.lead_id, - conversationId: r.conversation_id, - title: r.title, - description: r.description, - participantNotes: r.participant_notes, - eventType: r.event_type, - startTime: r.start_time, - endTime: r.end_time, - durationMinutes: r.duration_minutes, - status: r.status, + id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id, + leadId: r.lead_id, conversationId: r.conversation_id, + title: r.title, description: r.description, participantNotes: r.participant_notes, + eventType: r.event_type, startTime: r.start_time, endTime: r.end_time, + durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at, creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role }, - participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null, + participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null, developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null, lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null, - clientName: r.client_name || null, - clientEmail: r.client_email || null, - clientPhone: r.client_phone || null, - createdAt: r.created_at, + clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null, }, }, { status: 201 }) } catch (error) { diff --git a/src/app/api/invite/generate/route.ts b/src/app/api/invite/generate/route.ts index 2b37995..5de99d7 100644 --- a/src/app/api/invite/generate/route.ts +++ b/src/app/api/invite/generate/route.ts @@ -1,15 +1,38 @@ import { NextRequest, NextResponse } from "next/server" +import { getSessionUser, setSessionContext } from "@/lib/auth" import { query } from "@/lib/db" import crypto from "crypto" export async function POST(request: NextRequest) { try { - const { phone, token: clientToken } = await request.json() + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (sessionUser.role !== "super_admin") { + return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 }) + } + + const { phone } = await request.json() if (!phone) { return NextResponse.json({ error: "Phone number required" }, { status: 400 }) } - const token = clientToken || crypto.randomBytes(24).toString("hex") + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + request.headers.get("x-real-ip") || + "127.0.0.1" + + await setSessionContext(sessionUser.id, ipAddress) + + const recentCount = await query( + `SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`, + ) + if (parseInt(recentCount.rows[0]?.cnt || "0", 10) >= 10) { + return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 }) + } + + const token = crypto.randomBytes(24).toString("hex") await query( `INSERT INTO invites (token, phone) VALUES ($1, $2)`, [token, phone], @@ -19,7 +42,8 @@ export async function POST(request: NextRequest) { const inviteUrl = `${origin}/join/${token}` return NextResponse.json({ token, inviteUrl }) - } catch { + } catch (error) { + console.error("Invite generation error:", error) return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 }) } } diff --git a/src/app/api/leads/route.ts b/src/app/api/leads/route.ts index fa83e33..9e2943e 100644 --- a/src/app/api/leads/route.ts +++ b/src/app/api/leads/route.ts @@ -43,47 +43,74 @@ export async function GET(request: NextRequest) { const offset = parseInt(searchParams.get("offset") || "0", 10) const isAdmin = user.role === "admin" || user.role === "super_admin" - let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, - l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id, - ls.name AS stage_name, - u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url - FROM leads l - JOIN lead_stages ls ON ls.id = l.stage_id - LEFT JOIN users u ON u.id = l.assigned_to - WHERE l.deleted_at IS NULL` - const params: any[] = [] let paramIdx = 1 + const conditions: string[] = ["l.deleted_at IS NULL"] + if (period !== "all") { const range = getPeriodDateRange(period) if (range) { - sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}` + conditions.push(`l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`) params.push(range.start.toISOString(), range.end.toISOString()) paramIdx += 2 } } if (search) { - sql += ` AND (l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})` + conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`) params.push(`%${search}%`) paramIdx++ } if (!isAdmin) { - sql += ` AND l.assigned_to = $${paramIdx}` + conditions.push(`l.assigned_to = $${paramIdx}`) params.push(user.id) paramIdx++ } - sql += ` ORDER BY l.created_at DESC` - sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}` - params.push(limit, offset) - paramIdx += 2 + // Push status filter into SQL (avoid client-side filtering after pagination) + const statusStageMap: Record = { + open: ["New"], + contacted: ["Contacted"], + pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"], + closed: ["Closed Won"], + ignored: ["Closed Lost"], + } + if (status !== "all") { + const stageNames = statusStageMap[status] + if (stageNames) { + const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`) + conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`) + params.push(...stageNames) + paramIdx += stageNames.length + } + } - const result = await query(sql, params) + const whereClause = conditions.join(" AND ") - let leads = result.rows.map((r: any) => { + // Total count (same filters, no pagination) + const countResult = await query( + `SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`, + params.slice(0, paramIdx - 1), + ) + const total = parseInt(countResult.rows[0]?.total || "0", 10) + + // Data query with pagination + const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, + l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id, + ls.name AS stage_name, + u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url + FROM leads l + JOIN lead_stages ls ON ls.id = l.stage_id + LEFT JOIN users u ON u.id = l.assigned_to + WHERE ${whereClause} + ORDER BY l.created_at DESC + LIMIT $${paramIdx} OFFSET $${paramIdx + 1}` + const dataParams = [...params, limit, offset] + const result = await query(dataSql, dataParams) + + const leads = result.rows.map((r: any) => { const s = stageToStatus(r.stage_name) return { id: r.id, @@ -106,11 +133,7 @@ export async function GET(request: NextRequest) { } }) - if (status !== "all") { - leads = leads.filter((l: any) => l.status === status) - } - - return NextResponse.json(leads) + return NextResponse.json({ leads, total }) } catch (error) { console.error("Leads API error:", error) return NextResponse.json({ error: "Failed to load leads" }, { status: 500 }) diff --git a/src/app/api/users/lookup/route.ts b/src/app/api/users/lookup/route.ts index c4e6c2e..2d8f3df 100644 --- a/src/app/api/users/lookup/route.ts +++ b/src/app/api/users/lookup/route.ts @@ -1,7 +1,17 @@ import { NextRequest, NextResponse } from "next/server" +import { getSessionUser } from "@/lib/auth" import { query } from "@/lib/db" export async function GET(request: NextRequest) { + const sessionUser = await getSessionUser() + if (!sessionUser) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + const phone = request.nextUrl.searchParams.get("phone") if (!phone) { return NextResponse.json({ error: "Phone parameter required" }, { status: 400 }) diff --git a/src/components/chats/media-picker.tsx b/src/components/chats/media-picker.tsx index ce7a330..d0e97b2 100644 --- a/src/components/chats/media-picker.tsx +++ b/src/components/chats/media-picker.tsx @@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react" import data from "@emoji-mart/data" import Picker from "@emoji-mart/react" import { getStickerPacks, type StickerPack } from "@/data/stickers" +import { sanitizeSvg } from "@/lib/sanitize" type Tab = "emoji" | "gif" | "sticker" @@ -287,7 +288,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square" > {sticker.svg ? ( -
+
) : ( {sticker.emoji} )} @@ -303,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the if (!s) return null return ( ) })} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4da147c..b9da8a5 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,21 +1,7 @@ -import { SignJWT, jwtVerify } from "jose"; import bcrypt from "bcryptjs"; import { query } from "@/lib/db"; import { cookies } from "next/headers"; - -function randomHex(length: number): string { - const chars = "abcdef0123456789"; - let result = ""; - for (let i = 0; i < length; i++) { - result += chars[Math.floor(Math.random() * chars.length)]; - } - return result; -} - -const g = globalThis as typeof globalThis & { __JWT_RAW_SECRET?: string }; -const RAW_SECRET = process.env.JWT_SECRET || (g.__JWT_RAW_SECRET ??= randomHex(32)); -if (!RAW_SECRET) throw new Error("JWT_SECRET environment variable is required"); -const JWT_SECRET = new TextEncoder().encode(RAW_SECRET); +import { signToken, verifyToken } from "@/lib/jwt"; export const SESSION_COOKIE = "session"; const MAX_FAILED_ATTEMPTS = 5; @@ -43,23 +29,6 @@ export async function comparePassword( return bcrypt.compare(password, hash); } -export async function signToken(payload: { userId: string; role: string }) { - return new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime("24h") - .setIssuedAt() - .sign(JWT_SECRET); -} - -export async function verifyToken(token: string) { - try { - const { payload } = await jwtVerify(token, JWT_SECRET); - return payload as { userId: string; role: string }; - } catch { - return null; - } -} - export async function getUserByEmail(email: string) { const result = await query( ` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name, @@ -221,7 +190,7 @@ export async function decryptPassword(encrypted: string): Promise } export async function setSessionContext(userId: string, ip?: string) { - await query("SELECT set_config('app.current_user_id', $1, true)", [userId]); + await query("SELECT set_session_user_context($1)", [userId]); if (ip) { await query("SELECT set_config('app.current_ip', $1, true)", [ip]); } diff --git a/src/lib/db.ts b/src/lib/db.ts index ad7ffdc..60ff4cc 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,4 +1,4 @@ -import { Pool } from "pg" +import { Pool, PoolClient } from "pg" const dbUrl = process.env.DATABASE_URL if (!dbUrl) { @@ -9,6 +9,8 @@ const pool = new Pool({ max: 20, idleTimeoutMillis: 30000, connectionTimeoutMillis: 5000, + statement_timeout: 30000, + idle_in_transaction_session_timeout: 10000, ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false }, }) @@ -29,4 +31,25 @@ export async function query(text: string, params?: unknown[], userId?: string) { } } +export async function transaction( + callback: (client: PoolClient) => Promise, + userId?: string, +): Promise { + const client = await pool.connect() + try { + await client.query("BEGIN") + if (userId) { + await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId]) + } + const result = await callback(client) + await client.query("COMMIT") + return result + } catch (e) { + await client.query("ROLLBACK").catch(() => {}) + throw e + } finally { + client.release() + } +} + export default pool diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..8f2ae82 --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,34 @@ +import { jwtVerify, SignJWT } from "jose" + +let encoded: Uint8Array + +export function getJWTSecret(): Uint8Array { + if (!encoded) { + const raw = process.env.JWT_SECRET + if (!raw) { + throw new Error( + "JWT_SECRET environment variable is required. " + + "Generate one: openssl rand -hex 32", + ) + } + encoded = new TextEncoder().encode(raw) + } + return encoded +} + +export async function verifyToken(token: string) { + try { + const { payload } = await jwtVerify(token, getJWTSecret()) + return payload as { userId: string; role: string } + } catch { + return null + } +} + +export async function signToken(payload: { userId: string; role: string }) { + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("24h") + .setIssuedAt() + .sign(getJWTSecret()) +} diff --git a/src/lib/sanitize.ts b/src/lib/sanitize.ts new file mode 100644 index 0000000..73f5203 --- /dev/null +++ b/src/lib/sanitize.ts @@ -0,0 +1,6 @@ +export function sanitizeSvg(svg: string): string { + return svg + .replace(/)<[^<]*)*<\/script>/gi, "") + .replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "") + .replace(/javascript\s*:/gi, "") +} diff --git a/src/middleware.ts b/src/middleware.ts index 104f0e8..d3a277f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import type { NextRequest } from "next/server" +import { verifyToken } from "@/lib/jwt" const publicRoutes = [ "/login", @@ -12,6 +13,8 @@ const publicRoutes = [ "/fonts", ] +const SESSION_COOKIE = "session" + export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl @@ -23,9 +26,9 @@ export async function middleware(request: NextRequest) { return NextResponse.next() } - const sessionCookie = request.cookies.get("session")?.value + const token = request.cookies.get(SESSION_COOKIE)?.value - if (!sessionCookie) { + if (!token) { if (pathname.startsWith("/api/")) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -34,7 +37,21 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(loginUrl) } - return NextResponse.next() + const payload = await verifyToken(token) + if (!payload) { + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const loginUrl = new URL("/login", request.url) + loginUrl.searchParams.set("redirect", pathname) + return NextResponse.redirect(loginUrl) + } + + const requestHeaders = new Headers(request.headers) + requestHeaders.set("x-user-id", payload.userId) + requestHeaders.set("x-user-role", payload.role) + + return NextResponse.next({ request: { headers: requestHeaders } }) } export const config = { diff --git a/tests/api/bug-reports.test.ts b/tests/api/bug-reports.test.ts new file mode 100644 index 0000000..27cca62 --- /dev/null +++ b/tests/api/bug-reports.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest" + +const API_BASE = "http://localhost:3006" + +async function loginAs(role: string) { + const credentials: Record = { + admin: { email: "admin@coastit.co.za", password: "AdminAccess@2026" }, + superadmin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" }, + sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" }, + dev: { email: "dev@coastit.co.za", password: "DevTesting@2026" }, + } + const cred = credentials[role] + if (!cred) return null + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cred), + }) + if (res.status !== 200) return null + return res.headers.get("set-cookie")?.split(";")[0] +} + +describe("Bug Reports", () => { + it("allows any authenticated user to submit a bug report", async () => { + const cookie = await loginAs("dev") + if (!cookie) return + + const res = await fetch(`${API_BASE}/api/bug-reports`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ title: "Test bug", description: "This is a test bug report" }), + }) + expect(res.status).toBe(200) + }) + + it("rejects unauthenticated bug report submission", async () => { + const res = await fetch(`${API_BASE}/api/bug-reports`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Test", description: "Test" }), + }) + expect(res.status).toBe(401) + }) +}) diff --git a/tests/api/leads.test.ts b/tests/api/leads.test.ts new file mode 100644 index 0000000..88ed861 --- /dev/null +++ b/tests/api/leads.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest" + +// These tests require a running PostgreSQL database with the CRM schema. +// Run: npm run dev (starts the server on port 3006) +// Then: npx vitest run + +const API_BASE = "http://localhost:3006" + +async function loginAs(role: string) { + const credentials: Record = { + admin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" }, + sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" }, + } + const cred = credentials[role] || credentials.admin + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cred), + }) + if (res.status !== 200) return null + const setCookie = res.headers.get("set-cookie") + return setCookie?.split(";")[0] // return the raw cookie value +} + +describe("Leads API", () => { + it("creates a lead when authenticated", async () => { + const cookie = await loginAs("sales") + if (!cookie) return // skip if DB not available + + const res = await fetch(`${API_BASE}/api/leads`, { + method: "POST", + headers: { "Content-Type": "application/json", Cookie: cookie }, + body: JSON.stringify({ + company_name: "Test Company", + contact_name: "Test Contact", + email: "test@example.com", + }), + }) + expect(res.status).toBe(200) + + // Cleanup + const data = await res.json() + if (data?.id) { + await fetch(`${API_BASE}/api/leads/${data.id}`, { + method: "DELETE", + headers: { Cookie: cookie }, + }) + } + }) + + it("rejects unauthenticated lead creation", async () => { + const res = await fetch(`${API_BASE}/api/leads`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ company_name: "Test", contact_name: "Test", email: "test@test.com" }), + }) + expect(res.status).toBe(401) + }) +}) diff --git a/tests/auth/login.test.ts b/tests/auth/login.test.ts new file mode 100644 index 0000000..8d55846 --- /dev/null +++ b/tests/auth/login.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest" + +// These tests require a running PostgreSQL database with the CRM schema. +// Set DATABASE_URL env var or the default local connection will be used. +// +// Run: DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crm_test npx vitest run + +const API_BASE = "http://localhost:3006" + +function skipIfNoDb() { + if (!process.env.CI && !process.env.DATABASE_URL?.includes("crm_test")) { + console.warn("Skipping DB-dependent tests. Set DATABASE_URL to a test database.") + return true + } + return false +} + +describe("Login", () => { + it("returns 401 with wrong password", async () => { + if (skipIfNoDb()) return + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "superadmin@coastit.co.za", password: "wrong" }), + }) + expect(res.status).toBe(401) + }) + + it("returns 400 with empty body", async () => { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }) + expect(res.status).toBe(400) + }) + + it("returns 401 for non-existent user", async () => { + if (skipIfNoDb()) return + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: "nobody@example.com", password: "anything" }), + }) + expect(res.status).toBe(401) + }) +}) + +describe("Authorization", () => { + it("rejects unauthenticated requests to protected routes", async () => { + const res = await fetch(`${API_BASE}/api/leads`, { headers: { "Content-Type": "application/json" } }) + expect(res.status).toBe(401) + }) + + it("rejects unauthenticated requests to dashboard", async () => { + const res = await fetch(`${API_BASE}/api/dashboard`, { headers: { "Content-Type": "application/json" } }) + expect(res.status).toBe(401) + }) +}) diff --git a/tests/lib/jwt.test.ts b/tests/lib/jwt.test.ts new file mode 100644 index 0000000..0abdb98 --- /dev/null +++ b/tests/lib/jwt.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest" +import { signToken, verifyToken } from "@/lib/jwt" + +process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long-for-security" + +describe("JWT", () => { + it("signs and verifies a valid token", async () => { + const token = await signToken({ userId: "user-1", role: "admin" }) + expect(token).toBeTruthy() + expect(typeof token).toBe("string") + + const payload = await verifyToken(token) + expect(payload).not.toBeNull() + expect(payload!.userId).toBe("user-1") + expect(payload!.role).toBe("admin") + }) + + it("rejects a tampered token", async () => { + const token = await signToken({ userId: "user-1", role: "admin" }) + const tampered = token.slice(0, -5) + "XXXXX" + const payload = await verifyToken(tampered) + expect(payload).toBeNull() + }) + + it("rejects an expired token", async () => { + const { SignJWT } = await import("jose") + const { getJWTSecret } = await import("@/lib/jwt") + const expiredToken = await new SignJWT({ userId: "user-1", role: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("0s") + .sign(getJWTSecret()) + const payload = await verifyToken(expiredToken) + expect(payload).toBeNull() + }) + + it("rejects a token with invalid signature", async () => { + const { SignJWT } = await import("jose") + const wrongSecret = new TextEncoder().encode("different-secret-key-for-signing-purposes-only") + const token = await new SignJWT({ userId: "user-1", role: "admin" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("24h") + .setIssuedAt() + .sign(wrongSecret) + const payload = await verifyToken(token) + expect(payload).toBeNull() + }) + + it("returns null for empty token", async () => { + const payload = await verifyToken("") + expect(payload).toBeNull() + }) + + it("returns null for garbage token", async () => { + const payload = await verifyToken("this.is.not.a.jwt") + expect(payload).toBeNull() + }) +}) diff --git a/tests/lib/sanitize.test.ts b/tests/lib/sanitize.test.ts new file mode 100644 index 0000000..636dd94 --- /dev/null +++ b/tests/lib/sanitize.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest" +import { sanitizeSvg } from "@/lib/sanitize" + +describe("sanitizeSvg", () => { + it("strips script tags", () => { + const input = `` + const result = sanitizeSvg(input) + expect(result).not.toContain("script") + expect(result).toContain(" { + const input = `` + const result = sanitizeSvg(input) + expect(result).not.toContain("onload") + expect(result).toContain(" { + const input = `` + const result = sanitizeSvg(input) + expect(result).not.toContain("onclick") + expect(result).toContain(" { + const input = `click` + const result = sanitizeSvg(input) + expect(result).not.toContain("javascript:") + }) + + it("passes through clean SVGs unchanged (except whitespace)", () => { + const input = `` + const result = sanitizeSvg(input) + expect(result).toContain("viewBox") + expect(result).toContain("