From a933a42de5ea8a56b93e3aa3f12885bf72b88afa Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Sun, 18 May 2025 00:31:07 -0700 Subject: [PATCH] Persist users and orgs (#29) --- .github/workflows/CI.yml | 23 + next.config.ts | 5 + package.json | 8 +- pnpm-lock.yaml | 190 +++++++ src/actions/analytics.ts | 2 +- src/actions/auditLogs.ts | 31 +- src/actions/defaultParameters.ts | 23 +- src/actions/organizationSettings.ts | 23 +- src/actions/providerWhitelist.ts | 71 ++- .../(authenticated)/audit-logs/AuditLogs.tsx | 19 +- .../(authenticated)/dashboard/Dashboard.tsx | 4 +- src/app/layout.tsx | 13 +- src/components/audit-logs/AuditLogCard.tsx | 18 +- src/components/usage/Metric.tsx | 6 +- src/components/usage/UsageCard.tsx | 1 + src/components/usage/index.ts | 1 + src/db/index.ts | 30 +- .../migrations/0002_normal_mephistopheles.sql | 43 ++ src/db/migrations/0003_strange_wild_pack.sql | 2 + src/db/migrations/meta/0002_snapshot.json | 482 +++++++++++++++++ src/db/migrations/meta/0003_snapshot.json | 496 ++++++++++++++++++ src/db/migrations/meta/_journal.json | 14 + src/db/schema.ts | 195 +++++-- src/i18n/{ => __tests__}/locale.test.ts | 4 +- src/lib/{ => __tests__}/formatters.test.ts | 4 +- .../server/__tests__/syncCurrentUser.test.ts | 147 ++++++ src/lib/server/__tests__/syncOrg.test.ts | 117 +++++ src/lib/server/auditLogs.ts | 57 +- src/lib/server/sync.ts | 152 ++++++ src/middleware.ts | 3 - vitest-global-setup.ts | 36 ++ vitest-setup.ts | 3 +- vitest.config.mts | 7 +- 33 files changed, 2022 insertions(+), 208 deletions(-) create mode 100644 src/components/usage/index.ts create mode 100644 src/db/migrations/0002_normal_mephistopheles.sql create mode 100644 src/db/migrations/0003_strange_wild_pack.sql create mode 100644 src/db/migrations/meta/0002_snapshot.json create mode 100644 src/db/migrations/meta/0003_snapshot.json rename src/i18n/{ => __tests__}/locale.test.ts (97%) rename src/lib/{ => __tests__}/formatters.test.ts (96%) create mode 100644 src/lib/server/__tests__/syncCurrentUser.test.ts create mode 100644 src/lib/server/__tests__/syncOrg.test.ts create mode 100644 src/lib/server/sync.ts create mode 100644 vitest-global-setup.ts diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index f9a4e73a15..1fa64f753a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -38,6 +38,22 @@ jobs: test: runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15.4 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: roo_code_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout code uses: actions/checkout@v4 @@ -69,8 +85,15 @@ jobs: - name: Run type checker run: pnpm check-types + - name: Setup database schema + run: pnpm db:test:push + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/roo_code_test + - name: Run unit tests run: pnpm test # -- --coverage + env: + DATABASE_URL: postgres://postgres:password@localhost:5432/roo_code_test # - name: Upload coverage reports to Codecov # uses: codecov/codecov-action@v4 diff --git a/next.config.ts b/next.config.ts index f6dc76b2bc..797a5b7a68 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,11 @@ const nextConfig: NextConfig = { eslint: { dirs: ['.'] }, poweredByHeader: false, reactStrictMode: true, + // If node middleware were stable & enabled, then `syncAuth` could be + // implemented in the middleware instead of the layout. + // experimental: { + // nodeMiddleware: true, + // }, }; export default withSentryConfig(bundleAnalyzer(withNextIntl(nextConfig)), { diff --git a/package.json b/package.json index 631c50df6f..c058b3ca0d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "scripts": { "lint": "eslint .", "check-types": "tsc --noEmit --pretty", - "test": "vitest run", + "test": "dotenvx run -f .env.test -f .env -- vitest run", "test:e2e": "playwright test", "dev": "next dev --turbopack", "build": "next build", @@ -13,7 +13,12 @@ "clean": "rimraf .next out coverage", "db:generate": "dotenvx run -f .env.development -- drizzle-kit generate", "db:migrate": "dotenvx run -f .env.development -- drizzle-kit migrate", + "db:push": "dotenvx run -f .env.development -- drizzle-kit push", "db:studio": "dotenvx run -f .env.development -- drizzle-kit studio", + "db:test:generate": "dotenvx run -f .env.test -- drizzle-kit generate", + "db:test:migrate": "dotenvx run -f .env.test -- drizzle-kit migrate", + "db:test:push": "dotenvx run -f .env.test -- drizzle-kit push", + "db:test:studio": "dotenvx run -f .env.test -- drizzle-kit studio", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "storybook:serve": "http-server storybook-static --port 6006 --silent", @@ -56,6 +61,7 @@ "next": "^15.3.2", "next-intl": "^4.1.0", "next-themes": "^0.4.6", + "next-typesafe-url": "^5.1.7", "pg": "^8.13.0", "pino": "^9.5.0", "pino-pretty": "^13.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bc4717340..d7933662d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + next-typesafe-url: + specifier: ^5.1.7 + version: 5.1.7(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(zod@3.24.4) pg: specifier: ^8.13.0 version: 8.15.6 @@ -3614,6 +3617,9 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/minimist@1.2.5': + resolution: {integrity: sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -3623,6 +3629,9 @@ packages: '@types/node@22.15.17': resolution: {integrity: sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==} + '@types/normalize-package-data@2.4.4': + resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -4012,6 +4021,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + arrify@1.0.1: + resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} + engines: {node: '>=0.10.0'} + asn1.js@4.10.1: resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} @@ -4240,6 +4253,10 @@ packages: camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + camelcase-keys@6.2.2: + resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} + engines: {node: '>=8'} + camelcase@5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -4615,6 +4632,10 @@ packages: supports-color: optional: true + decamelize-keys@1.1.1: + resolution: {integrity: sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==} + engines: {node: '>=0.10.0'} + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -5495,6 +5516,10 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} + hard-rejection@2.1.0: + resolution: {integrity: sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==} + engines: {node: '>=6'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -5557,6 +5582,10 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + html-encoding-sniffer@3.0.0: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} @@ -5803,6 +5832,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-plain-obj@1.1.0: + resolution: {integrity: sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==} + engines: {node: '>=0.10.0'} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} @@ -6190,6 +6223,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -6354,6 +6391,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lucide-react@0.509.0: resolution: {integrity: sha512-xCJHn6Uh5qF6PGml25vveCTrHJZcqS1G1MVzWZK54ZQsOiCVJk4fwY3oyo5EXS2S+aqvTpWYIfJN+PesJ0quxg==} peerDependencies: @@ -6387,6 +6428,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-obj@1.0.1: + resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} + engines: {node: '>=0.10.0'} + map-obj@4.3.0: resolution: {integrity: sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==} engines: {node: '>=8'} @@ -6422,6 +6467,10 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + meow@9.0.0: + resolution: {integrity: sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==} + engines: {node: '>=10'} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -6495,6 +6544,10 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist-options@4.1.0: + resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} + engines: {node: '>= 6'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -6575,6 +6628,14 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next-typesafe-url@5.1.7: + resolution: {integrity: sha512-RJRhRygT1WGpq6w52CoN0iXMhSj87ap/4VOpDiNhFSmp9TBYMXf0HOf0ygY6L/puDxKw8J1FqaIOb2+78DfuGQ==} + hasBin: true + peerDependencies: + next: ^13.4.12 || ^14.0.0 || ^15.0.0 + react: ^18.2.0 || ^19.0.0 + zod: ^3.21.4 + next@15.3.2: resolution: {integrity: sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -6633,6 +6694,10 @@ packages: normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + normalize-package-data@3.0.3: + resolution: {integrity: sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==} + engines: {node: '>=10'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -7200,6 +7265,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@4.0.1: + resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==} + engines: {node: '>=8'} + randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -7297,10 +7366,18 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + read-pkg@3.0.0: resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} engines: {node: '>=4'} + read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -8125,6 +8202,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-newlines@3.0.1: + resolution: {integrity: sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==} + engines: {node: '>=8'} + ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -8235,6 +8316,10 @@ packages: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} + type-fest@0.18.1: + resolution: {integrity: sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==} + engines: {node: '>=10'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -8243,6 +8328,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -8718,6 +8807,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -8735,6 +8827,10 @@ packages: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -12391,6 +12487,8 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/minimist@1.2.5': {} + '@types/ms@2.1.0': optional: true @@ -12402,6 +12500,8 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/normalize-package-data@2.4.4': {} + '@types/parse-json@4.0.2': {} '@types/pg-pool@2.0.6': @@ -12905,6 +13005,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + arrify@1.0.1: {} + asn1.js@4.10.1: dependencies: bn.js: 4.12.2 @@ -13210,6 +13312,12 @@ snapshots: pascal-case: 3.1.2 tslib: 2.8.1 + camelcase-keys@6.2.2: + dependencies: + camelcase: 5.3.1 + map-obj: 4.3.0 + quick-lru: 4.0.1 + camelcase@5.3.1: {} camelcase@6.3.0: {} @@ -13593,6 +13701,11 @@ snapshots: dependencies: ms: 2.1.3 + decamelize-keys@1.1.1: + dependencies: + decamelize: 1.2.0 + map-obj: 1.0.1 + decamelize@1.2.0: {} decimal.js@10.5.0: {} @@ -14578,6 +14691,8 @@ snapshots: dependencies: duplexer: 0.1.2 + hard-rejection@2.1.0: {} + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -14637,6 +14752,10 @@ snapshots: hosted-git-info@2.8.9: {} + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + html-encoding-sniffer@3.0.0: dependencies: whatwg-encoding: 2.0.0 @@ -14916,6 +15035,8 @@ snapshots: is-number@7.0.0: {} + is-plain-obj@1.1.0: {} + is-plain-object@5.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -15544,6 +15665,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} lazy-ass@1.6.0: {} @@ -15693,6 +15816,10 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lucide-react@0.509.0(react@19.1.0): dependencies: react: 19.1.0 @@ -15728,6 +15855,8 @@ snapshots: dependencies: tmpl: 1.0.5 + map-obj@1.0.1: {} + map-obj@4.3.0: {} map-or-similar@1.5.0: {} @@ -15756,6 +15885,21 @@ snapshots: memorystream@0.3.1: {} + meow@9.0.0: + dependencies: + '@types/minimist': 1.2.5 + camelcase-keys: 6.2.2 + decamelize: 1.2.0 + decamelize-keys: 1.1.1 + hard-rejection: 2.1.0 + minimist-options: 4.1.0 + normalize-package-data: 3.0.3 + read-pkg-up: 7.0.1 + redent: 3.0.0 + trim-newlines: 3.0.1 + type-fest: 0.18.1 + yargs-parser: 20.2.9 + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -15812,6 +15956,12 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimist-options@4.1.0: + dependencies: + arrify: 1.0.1 + is-plain-obj: 1.1.0 + kind-of: 6.0.3 + minimist@1.2.8: {} minipass@4.2.8: {} @@ -15872,6 +16022,14 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + next-typesafe-url@5.1.7(next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)(zod@3.24.4): + dependencies: + chokidar: 3.6.0 + meow: 9.0.0 + next: 15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + zod: 3.24.4 + next@15.3.2(@babel/core@7.27.1)(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@next/env': 15.3.2 @@ -15956,6 +16114,13 @@ snapshots: semver: 5.7.2 validate-npm-package-license: 3.0.4 + normalize-package-data@3.0.3: + dependencies: + hosted-git-info: 4.1.0 + is-core-module: 2.16.1 + semver: 7.7.1 + validate-npm-package-license: 3.0.4 + normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -16563,6 +16728,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quick-lru@4.0.1: {} + randombytes@2.1.0: dependencies: safe-buffer: 5.2.1 @@ -16672,12 +16839,25 @@ snapshots: react@19.1.0: {} + read-pkg-up@7.0.1: + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + read-pkg@3.0.0: dependencies: load-json-file: 4.0.0 normalize-package-data: 2.5.0 path-type: 3.0.0 + read-pkg@5.2.0: + dependencies: + '@types/normalize-package-data': 2.4.4 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -17646,6 +17826,8 @@ snapshots: tree-kill@1.2.2: {} + trim-newlines@3.0.1: {} + ts-api-utils@2.1.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -17742,10 +17924,14 @@ snapshots: type-detect@4.0.8: {} + type-fest@0.18.1: {} + type-fest@0.20.2: {} type-fest@0.21.3: {} + type-fest@0.6.0: {} + type-fest@0.7.1: {} type-fest@0.8.1: {} @@ -18288,6 +18474,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yaml@1.10.2: {} @@ -18299,6 +18487,8 @@ snapshots: camelcase: 5.3.1 decamelize: 1.2.0 + yargs-parser@20.2.9: {} + yargs-parser@21.1.1: {} yargs@15.4.1: diff --git a/src/actions/analytics.ts b/src/actions/analytics.ts index 5e2906bd9c..5b18860602 100644 --- a/src/actions/analytics.ts +++ b/src/actions/analytics.ts @@ -33,7 +33,7 @@ export const getUsage = async ({ }: { orgId?: string | null; timePeriod: TimePeriod; -}): Promise => { +}): Promise => { if (!orgId) { return {}; } diff --git a/src/actions/auditLogs.ts b/src/actions/auditLogs.ts index 03d3c9f685..6d18bb45ee 100644 --- a/src/actions/auditLogs.ts +++ b/src/actions/auditLogs.ts @@ -3,23 +3,17 @@ import { eq, gte, and, desc } from 'drizzle-orm'; import { db } from '@/db'; -import { auditLogs } from '@/db/schema'; +import { auditLogs, type AuditLog } from '@/db/schema'; import { logger } from '@/lib/server/logger'; -import { AuditLog } from '@/db/schema'; -/** - * getAuditLogs - * - * Server action to retrieve audit logs for an organization - */ export const getAuditLogs = async ({ orgId, limit = 100, - nRecentDays, + timePeriod, }: { orgId?: string | null; limit?: number; - nRecentDays?: number; + timePeriod?: number; }): Promise => { if (!orgId) { return []; @@ -29,27 +23,27 @@ export const getAuditLogs = async ({ if (isNaN(limit) || limit <= 0) { throw new Error('Limit must be a positive number'); } - if (nRecentDays === undefined) { + + if (timePeriod === undefined) { return await db .select() .from(auditLogs) - .where(eq(auditLogs.organizationId, orgId)) + .where(eq(auditLogs.orgId, orgId)) .orderBy(desc(auditLogs.createdAt)) .limit(limit); } else { - if (isNaN(nRecentDays) || nRecentDays <= 0) { - throw new Error('nRecentDays must be a positive number'); + if (isNaN(timePeriod) || timePeriod <= 0) { + throw new Error('timePeriod must be a positive number'); } + const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - nRecentDays); + cutoff.setDate(cutoff.getDate() - timePeriod); + return await db .select() .from(auditLogs) .where( - and( - eq(auditLogs.organizationId, orgId), - gte(auditLogs.createdAt, cutoff), - ), + and(eq(auditLogs.orgId, orgId), gte(auditLogs.createdAt, cutoff)), ) .orderBy(desc(auditLogs.createdAt)) .limit(limit); @@ -59,6 +53,7 @@ export const getAuditLogs = async ({ event: 'audit_log_fetch_error', error: error instanceof Error ? error.message : 'Unknown error', }); + throw error; } }; diff --git a/src/actions/defaultParameters.ts b/src/actions/defaultParameters.ts index 40917c2111..dbb1d324a7 100644 --- a/src/actions/defaultParameters.ts +++ b/src/actions/defaultParameters.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { AuditLogTargetType } from '@/db/schema'; import { createAuditLog } from '@/lib/server/auditLogs'; + import { handleError, isAuthSuccess, @@ -48,24 +49,24 @@ export async function updateDefaultParameters( ): Promise { try { const authResult = await validateAuth(); - if (!isAuthSuccess(authResult)) return authResult; - const { userId, orgId } = authResult; + + if (!isAuthSuccess(authResult)) { + return authResult; + } const result = defaultParametersSchema.safeParse(data); + if (!result.success) { - return { - success: false, - error: 'Invalid request data', - }; + return { success: false, error: 'Invalid request data' }; } + const validatedData = result.data; - // TODO: persist data to the database - - // TODO: audit log description should contain more granular information on what changed + // TODO: Audit log description should contain more granular information on + // what changed. await createAuditLog({ - userId, - organizationId: orgId, + userId: authResult.userId, + orgId: authResult.orgId, targetType: AuditLogTargetType.DEFAULT_PARAMETERS, targetId: 'default-parameters', newValue: validatedData, diff --git a/src/actions/organizationSettings.ts b/src/actions/organizationSettings.ts index 831d8afbf2..9dfe923589 100644 --- a/src/actions/organizationSettings.ts +++ b/src/actions/organizationSettings.ts @@ -1,16 +1,13 @@ 'use server'; -import { auth } from '@clerk/nextjs/server'; -import { db } from '@/db'; -import { organizationSettings } from '@/db/schema'; import { eq } from 'drizzle-orm'; -import { type OrganizationSettings } from '@/schemas'; +import { auth } from '@clerk/nextjs/server'; + +import { db } from '@/db'; +import { orgSettings, type OrgSettings } from '@/db/schema'; -/** - * Get organization settings for the current organization - */ export async function getOrganizationSettings(): Promise< - OrganizationSettings | undefined + OrgSettings | undefined > { const { userId, orgId } = await auth(); @@ -24,13 +21,9 @@ export async function getOrganizationSettings(): Promise< const settings = await db .select() - .from(organizationSettings) - .where(eq(organizationSettings.organizationId, orgId)) + .from(orgSettings) + .where(eq(orgSettings.orgId, orgId)) .limit(1); - if (settings.length === 0) { - return; - } - - return settings[0]; + return settings.length === 0 ? undefined : settings[0]; } diff --git a/src/actions/providerWhitelist.ts b/src/actions/providerWhitelist.ts index 5d273e4072..24bd0106a8 100644 --- a/src/actions/providerWhitelist.ts +++ b/src/actions/providerWhitelist.ts @@ -33,30 +33,30 @@ type AllowAllProvidersRequest = z.infer; type ProviderToggleRequest = z.infer; type ModelToggleRequest = z.infer; -// Note: I haven't thought much about this API; this is a temporary stub to get audit logs working. +// Note: I haven't thought much about this API; this is a temporary stub to get +// audit logs working. // Feel free to refactor when implementing actual data writes. export async function updateAllowAllProviders( data: AllowAllProvidersRequest, ): Promise { try { const authResult = await validateAuth(); - if (!isAuthSuccess(authResult)) return authResult; - const { userId, orgId } = authResult; + + if (!isAuthSuccess(authResult)) { + return authResult; + } const result = allowAllProvidersSchema.safeParse(data); + if (!result.success) { - return { - success: false, - error: 'Invalid request data', - }; + return { success: false, error: 'Invalid request data' }; } + const validatedData = result.data; - // TODO: persist data to the database - await createAuditLog({ - userId, - organizationId: orgId, + userId: authResult.userId, + orgId: authResult.orgId, targetType: AuditLogTargetType.PROVIDER_WHITELIST, targetId: 'allow-all-providers', newValue: { allowAllProviders: validatedData.allowAllProviders }, @@ -72,30 +72,30 @@ export async function updateAllowAllProviders( } } -// Note: I haven't thought much about this API; this is a temporary stub to get audit logs working. +// Note: I haven't thought much about this API; this is a temporary stub to get +// audit logs working. // Feel free to refactor when implementing actual data writes. export async function updateProviderStatus( data: ProviderToggleRequest, ): Promise { try { const authResult = await validateAuth(); - if (!isAuthSuccess(authResult)) return authResult; - const { userId, orgId } = authResult; + + if (!isAuthSuccess(authResult)) { + return authResult; + } const result = providerToggleSchema.safeParse(data); + if (!result.success) { - return { - success: false, - error: 'Invalid request data', - }; + return { success: false, error: 'Invalid request data' }; } + const validatedData = result.data; - // TODO: persist data to the database - await createAuditLog({ - userId, - organizationId: orgId, + userId: authResult.userId, + orgId: authResult.orgId, targetType: AuditLogTargetType.PROVIDER_WHITELIST, targetId: validatedData.providerId, newValue: { enabled: validatedData.enabled }, @@ -111,40 +111,37 @@ export async function updateProviderStatus( } } -// Note: I haven't thought much about this API; this is a temporary stub to get audit logs working. +// Note: I haven't thought much about this API; this is a temporary stub to get +// audit logs working. // Feel free to refactor when implementing actual data writes. export async function updateModelStatus( data: ModelToggleRequest, ): Promise { try { const authResult = await validateAuth(); - if (!isAuthSuccess(authResult)) return authResult; - const { userId, orgId } = authResult; + + if (!isAuthSuccess(authResult)) { + return authResult; + } const result = modelToggleSchema.safeParse(data); + if (!result.success) { - return { - success: false, - error: 'Invalid request data', - }; + return { success: false, error: 'Invalid request data' }; } + const validatedData = result.data; - // TODO: persist data to the database - await createAuditLog({ - userId, - organizationId: orgId, + userId: authResult.userId, + orgId: authResult.orgId, targetType: AuditLogTargetType.PROVIDER_WHITELIST, targetId: `${validatedData.providerId}:${validatedData.modelId}`, newValue: { enabled: validatedData.enabled }, description: `${validatedData.enabled ? 'Enabled' : 'Disabled'} model ${validatedData.modelId} for provider ${validatedData.providerId}`, }); - return { - success: true, - message: 'Model status updated successfully', - }; + return { success: true, message: 'Model status updated successfully' }; } catch (error) { return handleError(error, 'model_toggle'); } diff --git a/src/app/(authenticated)/audit-logs/AuditLogs.tsx b/src/app/(authenticated)/audit-logs/AuditLogs.tsx index 6ec2aa8165..6487574b04 100644 --- a/src/app/(authenticated)/audit-logs/AuditLogs.tsx +++ b/src/app/(authenticated)/audit-logs/AuditLogs.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import { useOrganization } from '@clerk/nextjs'; +import { useAuth } from '@clerk/nextjs'; import { useQuery } from '@tanstack/react-query'; import type { AuditLog } from '@/db/schema'; @@ -19,21 +19,18 @@ import { import { AuditLogEntry, AuditLogDrawer } from '@/components/audit-logs'; export const AuditLogs = () => { - const { organization } = useOrganization(); + const { orgId } = useAuth(); + const limit = 20; const [timePeriod, setTimePeriod] = useState(7); - const [selectedLog, setSelectedLog] = useState(null); const { data: logs = [], isPending } = useQuery({ - queryKey: ['auditLogs', organization?.id, timePeriod], - queryFn: () => - getAuditLogs({ - orgId: organization?.id, - limit: 20, - nRecentDays: timePeriod, - }), - enabled: !!organization?.id, + queryKey: ['auditLogs', orgId, limit, timePeriod], + queryFn: () => getAuditLogs({ orgId, limit, timePeriod }), + enabled: !!orgId, }); + const [selectedLog, setSelectedLog] = useState(null); + return ( <> diff --git a/src/app/(authenticated)/dashboard/Dashboard.tsx b/src/app/(authenticated)/dashboard/Dashboard.tsx index dae5558747..7cc38406d9 100644 --- a/src/app/(authenticated)/dashboard/Dashboard.tsx +++ b/src/app/(authenticated)/dashboard/Dashboard.tsx @@ -1,5 +1,5 @@ -import { UsageCard } from '@/components/usage/UsageCard'; -import { AuditLogCard } from '@/components/audit-logs/AuditLogCard'; +import { UsageCard } from '@/components/usage'; +import { AuditLogCard } from '@/components/audit-logs'; export const Dashboard = () => ( <> diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 38c0c3bf86..31b692e085 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,9 +2,10 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { NextIntlClientProvider } from 'next-intl'; import { getLocale, getTranslations, setRequestLocale } from 'next-intl/server'; +import { auth } from '@clerk/nextjs/server'; import { getClerkLocale } from '@/i18n/locale'; -import { cn } from '@/lib/utils'; +import { syncAuth } from '@/lib/server/sync'; import { Toaster } from '@/components/ui'; import { ThemeProvider, @@ -40,8 +41,6 @@ export async function generateMetadata(): Promise { }; } -const geist = Geist({ subsets: ['latin'] }); - const fontSans = Geist({ variable: '--font-sans', subsets: ['latin'] }); const fontMono = Geist_Mono({ variable: '--font-mono', subsets: ['latin'] }); @@ -53,12 +52,10 @@ export default async function RootLayout({ const locale = await getLocale(); setRequestLocale(locale); + syncAuth(await auth()); + return ( - + (null); - const { organization } = useOrganization(); - - const path = usePathname(); + const { orgId } = useAuth(); + const limit = 5; + const timePeriod = undefined; const { data: logs = [], isPending } = useQuery({ - queryKey: ['auditLogs', organization?.id, 5], - queryFn: () => getAuditLogs({ orgId: organization?.id, limit: 5 }), - enabled: !!organization?.id, + queryKey: ['auditLogs', orgId, limit, timePeriod], + queryFn: () => getAuditLogs({ orgId, limit, timePeriod }), + enabled: !!orgId, }); + const [selectedLog, setSelectedLog] = useState(null); + const path = usePathname(); + return ( <> diff --git a/src/components/usage/Metric.tsx b/src/components/usage/Metric.tsx index 033df39976..49a36c80ef 100644 --- a/src/components/usage/Metric.tsx +++ b/src/components/usage/Metric.tsx @@ -17,9 +17,11 @@ export const Metric = ({ label, value, isPending }: MetricProps) => ( ) : typeof value === 'number' ? ( formatNumber(value) - ) : value ? ( + ) : typeof value !== 'undefined' ? ( value - ) : null} + ) : ( + 0 + )} ); diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx index c940ec5add..27d8b34f90 100644 --- a/src/components/usage/UsageCard.tsx +++ b/src/components/usage/UsageCard.tsx @@ -36,6 +36,7 @@ export const UsageCard = () => { const { data: usage = {}, isPending } = useQuery({ queryKey: ['usage', orgId, timePeriod], queryFn: () => getUsage({ orgId, timePeriod }), + enabled: !!orgId, }); return ( diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts new file mode 100644 index 0000000000..aa3a3a7fe2 --- /dev/null +++ b/src/components/usage/index.ts @@ -0,0 +1 @@ +export { UsageCard } from './UsageCard'; diff --git a/src/db/index.ts b/src/db/index.ts index af33e4ee80..e010c8734a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,29 @@ -import { Env } from '../lib/server/env'; - import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; -export const db = drizzle(Env.DATABASE_URL!); +import { Env } from '@/lib/server/env'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +let testDb: ReturnType | undefined = undefined; + +if (process.env.NODE_ENV === 'test') { + if ( + !Env.DATABASE_URL?.includes('test') || + !Env.DATABASE_URL?.includes('localhost') + ) { + throw new Error('DATABASE_URL is not a test database'); + } + + testDb = drizzle(pool); +} + +const db = process.env.NODE_ENV === 'test' ? testDb! : drizzle(pool); + +const disconnect = async () => { + await pool.end(); +}; + +export { db, testDb, disconnect }; diff --git a/src/db/migrations/0002_normal_mephistopheles.sql b/src/db/migrations/0002_normal_mephistopheles.sql new file mode 100644 index 0000000000..987feaeafb --- /dev/null +++ b/src/db/migrations/0002_normal_mephistopheles.sql @@ -0,0 +1,43 @@ +CREATE TABLE "organizations" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "slug" text NOT NULL, + "image_url" text NOT NULL, + "entity" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text, + "organization_role" text, + "name" text NOT NULL, + "email" text NOT NULL, + "image_url" text NOT NULL, + "entity" jsonb NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "audit_logs" ALTER COLUMN "id" SET DATA TYPE uuid;--> statement-breakpoint +ALTER TABLE "audit_logs" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();--> statement-breakpoint +ALTER TABLE "audit_logs" ALTER COLUMN "created_at" SET DATA TYPE timestamp;--> statement-breakpoint +ALTER TABLE "audit_logs" ALTER COLUMN "created_at" SET DEFAULT now();--> statement-breakpoint +ALTER TABLE "organization_settings" ALTER COLUMN "version" SET DEFAULT 1;--> statement-breakpoint +ALTER TABLE "organization_settings" ALTER COLUMN "allow_list" SET DEFAULT '{"allowAll":true,"providers":{}}'::jsonb;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "organizations_slug_idx" ON "organizations" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "organizations_created_at_idx" ON "organizations" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "users_organization_id_idx" ON "users" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "users_organization_role_idx" ON "users" USING btree ("organization_id","organization_role");--> statement-breakpoint +CREATE INDEX "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint +CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "organization_settings" ADD CONSTRAINT "organization_settings_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "audit_logs_user_id_idx" ON "audit_logs" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "audit_logs_organization_id_idx" ON "audit_logs" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "audit_logs_target_idx" ON "audit_logs" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs" USING btree ("created_at");--> statement-breakpoint +CREATE INDEX "organization_settings_created_at_idx" ON "organization_settings" USING btree ("created_at"); \ No newline at end of file diff --git a/src/db/migrations/0003_strange_wild_pack.sql b/src/db/migrations/0003_strange_wild_pack.sql new file mode 100644 index 0000000000..23edfaa6d7 --- /dev/null +++ b/src/db/migrations/0003_strange_wild_pack.sql @@ -0,0 +1,2 @@ +ALTER TABLE "organizations" ADD COLUMN "last_sync_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "last_sync_at" timestamp DEFAULT now() NOT NULL; \ No newline at end of file diff --git a/src/db/migrations/meta/0002_snapshot.json b/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000000..6b50351027 --- /dev/null +++ b/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,482 @@ +{ + "id": "c88e310b-6684-4083-a251-4e794bcefbbd", + "prevId": "a7b8c9d0-e1f2-3456-7890-a1b2c3d4e5f6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_target_idx": { + "name": "audit_logs_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "default_settings": { + "name": "default_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "allow_list": { + "name": "allow_list", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"allowAll\":true,\"providers\":{}}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_settings_created_at_idx": { + "name": "organization_settings_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_settings_organization_id_organizations_id_fk": { + "name": "organization_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_settings", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_idx": { + "name": "organizations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_id_idx": { + "name": "users_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_role_idx": { + "name": "users_organization_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/0003_snapshot.json b/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000000..6bd577a3db --- /dev/null +++ b/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,496 @@ +{ + "id": "bef500d1-5175-4068-af51-1f565355f1ac", + "prevId": "c88e310b-6684-4083-a251-4e794bcefbbd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_value": { + "name": "new_value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "audit_logs_user_id_idx": { + "name": "audit_logs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_target_idx": { + "name": "audit_logs_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "default_settings": { + "name": "default_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "allow_list": { + "name": "allow_list", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"allowAll\":true,\"providers\":{}}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_settings_created_at_idx": { + "name": "organization_settings_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_settings_organization_id_organizations_id_fk": { + "name": "organization_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_settings", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "organizations_created_at_idx": { + "name": "organizations_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity": { + "name": "entity", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_organization_id_idx": { + "name": "users_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_organization_role_idx": { + "name": "users_organization_role_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "users_organization_id_organizations_id_fk": { + "name": "users_organization_id_organizations_id_fk", + "tableFrom": "users", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index a1ce77f058..eda8c2a723 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1747347900000, "tag": "0001_add_organization_settings", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1747537218300, + "tag": "0002_normal_mephistopheles", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1747551227749, + "tag": "0003_strange_wild_pack", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 747caf3ca8..e05c6c89ea 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,8 +1,3 @@ -import { - ORGANIZATION_ALLOW_ALL, - OrganizationDefaultSettings, - type OrganizationAllowList, -} from '@/schemas'; import { pgTable, text, @@ -10,20 +5,163 @@ import { integer, jsonb, uuid, + index, } from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; -export const auditLogs = pgTable('audit_logs', { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - organizationId: text('organization_id').notNull(), - targetType: integer('target_type').notNull(), // AuditLogTargetType - targetId: text('target_id').notNull(), - newValue: jsonb('new_value').notNull(), - createdAt: timestamp('created_at', { withTimezone: true }) - .defaultNow() - .notNull(), - description: text('description').notNull(), -}); +import { + ORGANIZATION_ALLOW_ALL, + type OrganizationDefaultSettings, + type OrganizationAllowList, +} from '@/schemas'; + +/** + * users + */ + +export const users = pgTable( + 'users', + { + id: text('id').notNull().primaryKey(), // Assigned by Clerk. + orgId: text('organization_id').references(() => orgs.id), + orgRole: text('organization_role'), + name: text('name').notNull(), + email: text('email').notNull(), + imageUrl: text('image_url').notNull(), + entity: jsonb('entity').notNull(), + lastSyncAt: timestamp('last_sync_at').notNull().defaultNow(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('users_organization_id_idx').on(table.orgId), + index('users_organization_role_idx').on(table.orgId, table.orgRole), + index('users_email_idx').on(table.email), + index('users_created_at_idx').on(table.createdAt), + ], +); + +export const userRelations = relations(users, ({ one }) => ({ + org: one(orgs, { + fields: [users.orgId], + references: [orgs.id], + }), +})); + +export type User = typeof users.$inferSelect; + +export type CreateUser = Omit; + +/** + * organizations + */ + +export const orgs = pgTable( + 'organizations', + { + id: text('id').notNull().primaryKey(), // Assigned by Clerk. + name: text('name').notNull(), + slug: text('slug').notNull(), + imageUrl: text('image_url').notNull(), + entity: jsonb('entity').notNull(), + lastSyncAt: timestamp('last_sync_at').notNull().defaultNow(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('organizations_slug_idx').on(table.slug), + index('organizations_created_at_idx').on(table.createdAt), + ], +); + +export const orgsRelations = relations(orgs, ({ many, one }) => ({ + users: many(users), + auditLogs: many(auditLogs), + orgSettings: one(orgSettings, { + fields: [orgs.id], + references: [orgSettings.orgId], + }), +})); + +export type Org = typeof orgs.$inferSelect; + +export type CreateOrg = Omit; + +/** + * organization_settings + */ + +export const orgSettings = pgTable( + 'organization_settings', + { + orgId: text('organization_id') + .notNull() + .primaryKey() + .references(() => orgs.id), // Assigned by Clerk. + version: integer('version').notNull().default(1), + defaultSettings: jsonb('default_settings') + .notNull() + .$type() + .default({}), + allowList: jsonb('allow_list') + .notNull() + .$type() + .default(ORGANIZATION_ALLOW_ALL), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => [ + index('organization_settings_created_at_idx').on(table.createdAt), + ], +); + +export const orgSettingsRelations = relations(orgSettings, ({ one }) => ({ + org: one(orgs, { + fields: [orgSettings.orgId], + references: [orgs.id], + }), +})); + +export type OrgSettings = typeof orgSettings.$inferSelect; + +/** + * audit_logs + */ + +export const auditLogs = pgTable( + 'audit_logs', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: text('user_id') + .notNull() + .references(() => users.id), + orgId: text('organization_id') + .notNull() + .references(() => orgs.id), + targetType: integer('target_type').notNull(), // AuditLogTargetType + targetId: text('target_id').notNull(), + newValue: jsonb('new_value').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + description: text('description').notNull(), + }, + (table) => [ + index('audit_logs_user_id_idx').on(table.userId), + index('audit_logs_organization_id_idx').on(table.orgId), + index('audit_logs_target_idx').on(table.targetType, table.targetId), + index('audit_logs_created_at_idx').on(table.createdAt), + ], +); + +export const auditLogsRelations = relations(auditLogs, ({ one }) => ({ + user: one(users, { + fields: [auditLogs.userId], + references: [users.id], + }), + org: one(orgs, { + fields: [auditLogs.orgId], + references: [orgs.id], + }), +})); export enum AuditLogTargetType { PROVIDER_WHITELIST = 1, @@ -35,20 +173,9 @@ export type AuditLog = typeof auditLogs.$inferSelect & { targetType: AuditLogTargetType; }; -export const organizationSettings = pgTable('organization_settings', { - // Organization ID (from Clerk) - organizationId: text('organization_id').notNull().primaryKey(), - - // Version number, incremented on updates - version: integer('version').notNull().default(1), - defaultSettings: jsonb('default_settings') - .notNull() - .$type() - .default({}), - allowList: jsonb('allow_list') - .notNull() - .$type() - .default(ORGANIZATION_ALLOW_ALL), - createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow(), -}); +export type CreateAuditLog = Omit< + typeof auditLogs.$inferInsert, + 'id' | 'createdAt' +> & { + targetType: AuditLogTargetType; +}; diff --git a/src/i18n/locale.test.ts b/src/i18n/__tests__/locale.test.ts similarity index 97% rename from src/i18n/locale.test.ts rename to src/i18n/__tests__/locale.test.ts index 6ef7477d73..8483cbcd9c 100644 --- a/src/i18n/locale.test.ts +++ b/src/i18n/__tests__/locale.test.ts @@ -1,8 +1,8 @@ -// npx vitest run src/lib/locale.test.ts +// pnpm test src/i18n/__tests__/locale.test.ts import { enUS, frFR } from '@clerk/localizations'; -import { locales, isLocale, Locales, getClerkLocale, Locale } from './locale'; +import { locales, isLocale, Locales, getClerkLocale, Locale } from '../locale'; describe('locale module', () => { describe('locales', () => { diff --git a/src/lib/formatters.test.ts b/src/lib/__tests__/formatters.test.ts similarity index 96% rename from src/lib/formatters.test.ts rename to src/lib/__tests__/formatters.test.ts index e9c2a3bba8..e516dba7e4 100644 --- a/src/lib/formatters.test.ts +++ b/src/lib/__tests__/formatters.test.ts @@ -1,6 +1,6 @@ -// npx vitest run src/lib/formatters.test.ts +// pnpm test src/lib/__tests__/formatters.test.ts -import { formatNumber, formatCurrency } from './formatters'; +import { formatNumber, formatCurrency } from '../formatters'; describe('formatNumber', () => { it('should return empty string for undefined or null values', () => { diff --git a/src/lib/server/__tests__/syncCurrentUser.test.ts b/src/lib/server/__tests__/syncCurrentUser.test.ts new file mode 100644 index 0000000000..dca84a3108 --- /dev/null +++ b/src/lib/server/__tests__/syncCurrentUser.test.ts @@ -0,0 +1,147 @@ +// pnpm test src/lib/server/__tests__/syncCurrentUser.test.ts + +import { db } from '@/db'; +import { users, orgs } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +import { syncCurrentUser } from '../sync'; +import { logger } from '../logger'; + +const testUserId = 'fake-user-id'; +const testOrgId = 'fake-org-id'; + +vi.mock('@clerk/nextjs/server', () => ({ + currentUser: vi.fn().mockResolvedValue({ + id: 'fake-user-id', + firstName: 'Test', + lastName: 'User', + fullName: 'Test User', + emailAddresses: [{ emailAddress: 'test@example.com' }], + imageUrl: 'https://example.com/image.jpg', + }), +})); + +vi.mock('../logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('syncCurrentUser', () => { + beforeEach(async () => { + await db.delete(users).where(eq(users.id, testUserId)); + await db.delete(orgs).where(eq(orgs.id, testOrgId)); + + await db.insert(orgs).values({ + id: testOrgId, + name: 'Test Organization', + slug: 'test-org', + imageUrl: 'https://example.com/org-image.jpg', + entity: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + }); + + it('should create a new user if one does not exist', async () => { + await syncCurrentUser({ userId: testUserId }); + + const result = await db + .select() + .from(users) + .where(eq(users.id, testUserId)); + + expect(result.length).toBe(1); + const user = result[0]; + expect(user).toBeDefined(); + expect(user!.id).toBe(testUserId); + expect(user!.name).toBe('Test User'); + expect(user!.email).toBe('test@example.com'); + expect(user!.imageUrl).toBe('https://example.com/image.jpg'); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'user_created', + userId: testUserId, + orgId: undefined, + orgRole: undefined, + }), + ); + }); + + it('should update an existing user if data has changed', async () => { + await db.insert(users).values({ + id: testUserId, + name: 'Old Name', + email: 'old@example.com', + imageUrl: 'https://example.com/old-image.jpg', + entity: {}, + lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // Set lastSyncAt to 2 hours ago + createdAt: new Date(), + updatedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }); + + await syncCurrentUser({ userId: testUserId }); + + const result = await db + .select() + .from(users) + .where(eq(users.id, testUserId)); + + expect(result.length).toBe(1); + const user = result[0]; + expect(user).toBeDefined(); + expect(user!.name).toBe('Test User'); + expect(user!.email).toBe('test@example.com'); + expect(user!.imageUrl).toBe('https://example.com/image.jpg'); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'user_updated', + userId: testUserId, + orgId: undefined, + orgRole: undefined, + }), + ); + }); + + it('should update user organization if orgId is provided', async () => { + await db.insert(users).values({ + id: testUserId, + name: 'Test User', + email: 'test@example.com', + imageUrl: 'https://example.com/image.jpg', + entity: {}, + lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // Set lastSyncAt to 2 hours ago + createdAt: new Date(), + updatedAt: new Date(), + }); + + await syncCurrentUser({ + userId: testUserId, + orgId: testOrgId, + orgRole: 'admin', + }); + + const result = await db + .select() + .from(users) + .where(eq(users.id, testUserId)); + + expect(result.length).toBe(1); + const user = result[0]; + expect(user).toBeDefined(); + expect(user!.orgId).toBe(testOrgId); + expect(user!.orgRole).toBe('admin'); + + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'user_updated', + userId: testUserId, + orgId: testOrgId, + orgRole: 'admin', + }), + ); + }); +}); diff --git a/src/lib/server/__tests__/syncOrg.test.ts b/src/lib/server/__tests__/syncOrg.test.ts new file mode 100644 index 0000000000..9faef2d800 --- /dev/null +++ b/src/lib/server/__tests__/syncOrg.test.ts @@ -0,0 +1,117 @@ +// pnpm test src/lib/server/__tests__/syncOrg.test.ts + +import { eq } from 'drizzle-orm'; +import { clerkClient } from '@clerk/nextjs/server'; + +import { db } from '@/db'; +import { orgs } from '@/db/schema'; + +import { syncOrg } from '../sync'; +import { logger } from '../logger'; + +vi.mock('@clerk/nextjs/server', () => ({ + clerkClient: vi.fn().mockResolvedValue({ + organizations: { + getOrganization: vi.fn().mockResolvedValue({ + id: 'test-org-id', + name: 'Test Organization', + slug: 'test-org', + imageUrl: 'https://example.com/org-image.jpg', + }), + }, + }), +})); + +vi.mock('../logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('syncOrg', () => { + it('should create a new organization if one does not exist', async () => { + await syncOrg('test-org-id'); + + const result = await db + .select() + .from(orgs) + .where(eq(orgs.id, 'test-org-id')); + + expect(result.length).toBe(1); + const org = result[0]; + expect(org).toBeDefined(); + expect(org!.id).toBe('test-org-id'); + expect(org!.name).toBe('Test Organization'); + expect(org!.slug).toBe('test-org'); + expect(org!.imageUrl).toBe('https://example.com/org-image.jpg'); + }); + + it('should update an existing organization if data has changed', async () => { + await db.delete(orgs).where(eq(orgs.id, 'test-org-id')); + + await db.insert(orgs).values({ + id: 'test-org-id', + name: 'Old Name', + slug: 'old-slug', + imageUrl: 'https://example.com/old-image.jpg', + entity: {}, + lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // Set lastSyncAt to 2 hours ago + createdAt: new Date(), + updatedAt: new Date(), + }); + + await syncOrg('test-org-id'); + + const result = await db + .select() + .from(orgs) + .where(eq(orgs.id, 'test-org-id')); + + expect(result.length).toBe(1); + const org = result[0]; + expect(org).toBeDefined(); + expect(org!.name).toBe('Test Organization'); + expect(org!.slug).toBe('test-org'); + expect(org!.imageUrl).toBe('https://example.com/org-image.jpg'); + }); + + it('should not update if organization data has not changed', async () => { + await db.delete(orgs).where(eq(orgs.id, 'test-org-id')); + + await db.insert(orgs).values({ + id: 'test-org-id', + name: 'Test Organization', + slug: 'test-org', + imageUrl: 'https://example.com/org-image.jpg', + entity: {}, + lastSyncAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // Set lastSyncAt to 2 hours ago + createdAt: new Date(), + updatedAt: new Date(), + }); + + vi.mocked(logger.info).mockClear(); + + await syncOrg('test-org-id'); + + expect(logger.info).not.toHaveBeenCalledWith( + expect.objectContaining({ event: 'organization_updated' }), + ); + }); + + it('should handle errors gracefully', async () => { + await db.delete(orgs).where(eq(orgs.id, 'test-org-id')); + + vi.mocked(clerkClient).mockRejectedValueOnce(new Error('Clerk API error')); + + await syncOrg('test-org-id'); + + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + event: 'org_sync_error', + orgId: 'test-org-id', + error: 'Clerk API error', + }), + ); + }); +}); diff --git a/src/lib/server/auditLogs.ts b/src/lib/server/auditLogs.ts index cd888a1253..029d71d906 100644 --- a/src/lib/server/auditLogs.ts +++ b/src/lib/server/auditLogs.ts @@ -1,52 +1,17 @@ import { db } from '@/db'; -import { auditLogs } from '@/db/schema'; +import { auditLogs, type CreateAuditLog } from '@/db/schema'; import { logger } from '@/lib/server/logger'; -import type { z } from 'zod'; -import { createInsertSchema } from 'drizzle-zod'; -type AuditLogCreateRequest = z.infer< - ReturnType> ->; - -/** - * Server-side function to create audit logs in the database - * @param request The audit log data to create - * @returns Object containing success status or error - */ -export async function createAuditLog(request: AuditLogCreateRequest): Promise<{ - success: boolean; - error?: string | Record; -}> { +export async function createAuditLog(values: CreateAuditLog) { try { - await db.insert(auditLogs).values({ - userId: request.userId, - organizationId: request.organizationId, - targetType: request.targetType, - targetId: request.targetId, - newValue: request.newValue, - description: request.description, - // id and createdAt will be set automatically by the database - }); - - logger.info({ - event: 'audit_log_created', - userId: request.userId, - organizationId: request.organizationId, - targetType: request.targetType, - }); - - return { - success: true, - }; - } catch (error) { - logger.error({ - event: 'audit_log_creation_error', - error: error instanceof Error ? error.message : 'Unknown error', - }); - return { - success: false, - error: - error instanceof Error ? error.message : 'An unexpected error occurred', - }; + await db.insert(auditLogs).values(values); + const { userId, orgId, targetType } = values; + logger.info({ userId, orgId, targetType }); + return { success: true }; + } catch (e) { + const error = + e instanceof Error ? e.message : 'An unexpected error occurred'; + logger.error({ event: 'audit_log_creation_error', error }); + return { success: false, error }; } } diff --git a/src/lib/server/sync.ts b/src/lib/server/sync.ts new file mode 100644 index 0000000000..76d2a0dc29 --- /dev/null +++ b/src/lib/server/sync.ts @@ -0,0 +1,152 @@ +import { eq } from 'drizzle-orm'; +import { clerkClient, currentUser } from '@clerk/nextjs/server'; + +import { db } from '@/db'; +import { users, type CreateUser, orgs, type CreateOrg } from '@/db/schema'; + +import { logger } from './logger'; + +export async function syncCurrentUser({ + userId, + orgId, + orgRole, +}: { + userId: string; + orgId?: string | null; + orgRole?: string | null; +}) { + try { + const existingUsers = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const existingUser = + existingUsers.length > 0 ? existingUsers[0] : undefined; + + if ( + existingUser && + existingUser.lastSyncAt.getTime() > Date.now() - 60_000 + ) { + return; + } + + const entity = await currentUser(); + + if (!entity) { + throw new Error('Unauthorized'); + } + + const newUser: CreateUser = { + orgId, + orgRole, + name: entity.fullName || entity.username || '', + email: entity.emailAddresses[0]?.emailAddress || '', + imageUrl: entity.imageUrl, + entity: entity, + }; + + if (!existingUser) { + await db.insert(users).values({ id: userId, ...newUser }); + logger.info({ event: 'user_created', userId, orgId, orgRole }); + } else { + const isDirty = + existingUser.orgId !== orgId || + existingUser.orgRole !== newUser.orgRole || + existingUser.name !== newUser.name || + existingUser.email !== newUser.email || + existingUser.imageUrl !== newUser.imageUrl || + existingUser.updatedAt.getTime() < Date.now() - 3_600_000; + + if (isDirty) { + await db + .update(users) + .set({ ...newUser, lastSyncAt: new Date(), updatedAt: new Date() }) + .where(eq(users.id, userId)); + logger.info({ event: 'user_updated', userId, orgId, orgRole }); + } else { + await db + .update(users) + .set({ lastSyncAt: new Date(), updatedAt: new Date() }) + .where(eq(users.id, userId)); + logger.info({ event: 'user_sync', userId, orgId, orgRole }); + } + } + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + logger.error({ event: 'user_sync_error', error }); + } +} + +export async function syncOrg(orgId: string) { + try { + const existingOrgs = await db + .select() + .from(orgs) + .where(eq(orgs.id, orgId)) + .limit(1); + + const existingOrg = existingOrgs.length > 0 ? existingOrgs[0] : undefined; + + if (existingOrg && existingOrg.lastSyncAt.getTime() > Date.now() - 60_000) { + return; + } + + const client = await clerkClient(); + + const entity = await client.organizations.getOrganization({ + organizationId: orgId, + }); + + const { name, slug, imageUrl } = entity; + const newOrg: CreateOrg = { name, slug, imageUrl, entity }; + + if (!existingOrg) { + await db.insert(orgs).values({ id: orgId, ...newOrg }); + logger.info({ event: 'org_created', orgId }); + } else { + const isDirty = + existingOrg.name !== name || + existingOrg.slug !== slug || + existingOrg.imageUrl !== imageUrl || + existingOrg.updatedAt.getTime() < Date.now() - 3_600_000; + + if (isDirty) { + await db + .update(orgs) + .set({ ...newOrg, lastSyncAt: new Date(), updatedAt: new Date() }) + .where(eq(orgs.id, orgId)); + + logger.info({ event: 'org_updated', orgId }); + } else { + await db + .update(orgs) + .set({ lastSyncAt: new Date(), updatedAt: new Date() }) + .where(eq(orgs.id, orgId)); + logger.info({ event: 'org_sync', orgId }); + } + } + } catch (e) { + const error = e instanceof Error ? e.message : 'Unknown error'; + logger.error({ event: 'org_sync_error', orgId, error }); + } +} + +type SyncAuth = { + userId: string | null; + orgId?: string | null; + orgRole?: string | null; +}; + +export async function syncAuth({ userId, orgId, orgRole }: SyncAuth) { + if (!userId) { + return; + } + + syncCurrentUser({ userId, orgId, orgRole }); + + if (orgId) { + syncOrg(orgId); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index f60e8c5a69..3e2eeea616 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -9,10 +9,7 @@ const isUnprotectedRoute = createRouteMatcher([ export default clerkMiddleware( async (auth, req) => { if (!isUnprotectedRoute(req)) { - console.log(`clerkMiddleware: PROTECTED -> ${req.nextUrl.pathname}`); await auth.protect(); - } else { - console.log(`clerkMiddleware: UNPROTECTED -> ${req.nextUrl.pathname}`); } }, { debug: false }, diff --git a/vitest-global-setup.ts b/vitest-global-setup.ts new file mode 100644 index 0000000000..4a36f20852 --- /dev/null +++ b/vitest-global-setup.ts @@ -0,0 +1,36 @@ +import { sql } from 'drizzle-orm'; + +import { Env } from '@/lib/server/env'; +import { testDb, disconnect } from '@/db'; + +async function resetTestDatabase() { + const db = testDb!; + + try { + const tables = await db.execute<{ table_name: string }>(sql` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE'; + `); + + const tableNames = (tables.rows || []).map(({ table_name }) => table_name); + + for (const tableName of tableNames) { + await db.execute(sql`TRUNCATE TABLE "${sql.raw(tableName)}" CASCADE;`); + } + + console.log(`[${Env.DATABASE_URL}] TRUNCATE ${tableNames.join(', ')}`); + } catch (error) { + console.error('Error resetting database:', error); + throw error; + } +} + +export default async function () { + await resetTestDatabase(); + + return async () => { + await disconnect(); + }; +} diff --git a/vitest-setup.ts b/vitest-setup.ts index 84135d937d..3f0fa19c07 100644 --- a/vitest-setup.ts +++ b/vitest-setup.ts @@ -1,5 +1,4 @@ import '@testing-library/jest-dom/vitest'; - import failOnConsole from 'vitest-fail-on-console'; failOnConsole({ @@ -9,3 +8,5 @@ failOnConsole({ shouldFailOnLog: true, shouldFailOnWarn: true, }); + +beforeEach(() => vi.clearAllMocks()); diff --git a/vitest.config.mts b/vitest.config.mts index 806039d6f9..08626c5a23 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,13 +5,14 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { - globals: true, // This is needed by @testing-library to be cleaned up after each test + globals: true, // This is needed by @testing-library to be cleaned up after each test. include: ['src/**/*.test.{js,jsx,ts,tsx}'], coverage: { include: ['src/**/*'], exclude: ['src/**/*.stories.{js,jsx,ts,tsx}', '**/*.d.ts'], }, - environment: 'jsdom', - setupFiles: ['./vitest-setup.ts'], + environment: 'node', // jsdom + setupFiles: './vitest-setup.ts', + globalSetup: './vitest-global-setup.ts', }, });