Persist users and orgs (#29)

This commit is contained in:
Chris Estreich 2025-05-18 00:31:07 -07:00 committed by GitHub
parent 82b0e45668
commit a933a42de5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 2022 additions and 208 deletions

View file

@ -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

View file

@ -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)), {

View file

@ -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",

190
pnpm-lock.yaml generated
View file

@ -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:

View file

@ -33,7 +33,7 @@ export const getUsage = async ({
}: {
orgId?: string | null;
timePeriod: TimePeriod;
}): Promise<UsageRecord> => {
}): Promise<UsageRecord | undefined> => {
if (!orgId) {
return {};
}

View file

@ -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<AuditLog[]> => {
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;
}
};

View file

@ -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<ApiResponse> {
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,

View file

@ -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];
}

View file

@ -33,30 +33,30 @@ type AllowAllProvidersRequest = z.infer<typeof allowAllProvidersSchema>;
type ProviderToggleRequest = z.infer<typeof providerToggleSchema>;
type ModelToggleRequest = z.infer<typeof modelToggleSchema>;
// 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<ApiResponse> {
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<ApiResponse> {
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<ApiResponse> {
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');
}

View file

@ -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<TimePeriod>(7);
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(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<AuditLog | null>(null);
return (
<>
<Card>

View file

@ -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 = () => (
<>

View file

@ -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<Metadata> {
};
}
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 (
<html
lang={locale}
className={cn(geist.className)}
suppressHydrationWarning
>
<html lang={locale} suppressHydrationWarning>
<body
className={`${fontSans.variable} ${fontMono.variable} font-sans antialiased`}
suppressHydrationWarning

View file

@ -3,7 +3,7 @@
import { usePathname } from 'next/navigation';
import Link from 'next/link';
import { useState } from 'react';
import { useOrganization } from '@clerk/nextjs';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import { ArrowRightIcon } from 'lucide-react';
@ -23,17 +23,19 @@ import { AuditLogEntry } from '@/components/audit-logs';
import { AuditLogDrawer } from './AuditLogDrawer';
export function AuditLogCard() {
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(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<AuditLog | null>(null);
const path = usePathname();
return (
<>
<Card>

View file

@ -17,9 +17,11 @@ export const Metric = ({ label, value, isPending }: MetricProps) => (
</div>
) : typeof value === 'number' ? (
formatNumber(value)
) : value ? (
) : typeof value !== 'undefined' ? (
value
) : null}
) : (
0
)}
</div>
</div>
);

View file

@ -36,6 +36,7 @@ export const UsageCard = () => {
const { data: usage = {}, isPending } = useQuery({
queryKey: ['usage', orgId, timePeriod],
queryFn: () => getUsage({ orgId, timePeriod }),
enabled: !!orgId,
});
return (

View file

@ -0,0 +1 @@
export { UsageCard } from './UsageCard';

View file

@ -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<typeof drizzle> | 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 };

View file

@ -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");

View file

@ -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;

View file

@ -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": {}
}
}

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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<typeof users.$inferInsert, 'id' | 'createdAt'>;
/**
* 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<typeof orgs.$inferInsert, 'id' | 'createdAt'>;
/**
* 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<OrganizationDefaultSettings>()
.default({}),
allowList: jsonb('allow_list')
.notNull()
.$type<OrganizationAllowList>()
.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<OrganizationDefaultSettings>()
.default({}),
allowList: jsonb('allow_list')
.notNull()
.$type<OrganizationAllowList>()
.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;
};

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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',
}),
);
});
});

View file

@ -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',
}),
);
});
});

View file

@ -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<typeof createInsertSchema<typeof auditLogs>>
>;
/**
* 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<string, unknown>;
}> {
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 };
}
}

152
src/lib/server/sync.ts Normal file
View file

@ -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);
}
}

View file

@ -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 },

36
vitest-global-setup.ts Normal file
View file

@ -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();
};
}

View file

@ -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());

View file

@ -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',
},
});