diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b73f891..ee6aceb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.5 - version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) browser-ext: {} @@ -52,6 +52,12 @@ importers: '@typeschema/valibot': specifier: ^0.13.4 version: 0.13.5(valibot@0.29.0) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@types/pg@8.20.0)(pg@8.21.0) + pg: + specifier: ^8.21.0 + version: 8.21.0 solid-js: specifier: ^1.9.5 version: 1.9.13 @@ -65,6 +71,12 @@ importers: specifier: ^7.0.0 version: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) devDependencies: + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + drizzle-kit: + specifier: ^0.31.10 + version: 0.31.10 jsdom: specifier: ^29.1.1 version: 29.1.1 @@ -277,6 +289,17 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -295,6 +318,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -313,6 +342,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -331,6 +366,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -349,6 +390,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -367,6 +414,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -385,6 +438,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -403,6 +462,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -421,6 +486,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -439,6 +510,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -457,6 +534,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -475,6 +558,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -493,6 +582,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -511,6 +606,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -529,6 +630,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -547,6 +654,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -565,6 +678,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -601,6 +720,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -637,6 +762,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -673,6 +804,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -691,6 +828,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -709,6 +852,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -727,6 +876,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1336,6 +1491,9 @@ packages: '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -1804,6 +1962,102 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -1860,6 +2114,11 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1971,6 +2230,9 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + giget@3.2.0: resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} hasBin: true @@ -2496,6 +2758,40 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2517,6 +2813,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -2594,6 +2906,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -2739,6 +3054,10 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + srvx@0.9.8: resolution: {integrity: sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ==} engines: {node: '>=20.16.0'} @@ -2896,6 +3215,11 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + tsx@4.22.3: + resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo@2.9.14: resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true @@ -3225,6 +3549,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3507,6 +3835,18 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3516,6 +3856,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.0': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true @@ -3525,6 +3868,9 @@ snapshots: '@esbuild/android-arm64@0.28.0': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.12': optional: true @@ -3534,6 +3880,9 @@ snapshots: '@esbuild/android-arm@0.28.0': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.12': optional: true @@ -3543,6 +3892,9 @@ snapshots: '@esbuild/android-x64@0.28.0': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true @@ -3552,6 +3904,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.0': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true @@ -3561,6 +3916,9 @@ snapshots: '@esbuild/darwin-x64@0.28.0': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -3570,6 +3928,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.0': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true @@ -3579,6 +3940,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.0': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true @@ -3588,6 +3952,9 @@ snapshots: '@esbuild/linux-arm64@0.28.0': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true @@ -3597,6 +3964,9 @@ snapshots: '@esbuild/linux-arm@0.28.0': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true @@ -3606,6 +3976,9 @@ snapshots: '@esbuild/linux-ia32@0.28.0': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true @@ -3615,6 +3988,9 @@ snapshots: '@esbuild/linux-loong64@0.28.0': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true @@ -3624,6 +4000,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.0': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true @@ -3633,6 +4012,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.0': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true @@ -3642,6 +4024,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.0': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true @@ -3651,6 +4036,9 @@ snapshots: '@esbuild/linux-s390x@0.28.0': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -3669,6 +4057,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.0': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -3687,6 +4078,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.0': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -3705,6 +4099,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.0': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true @@ -3714,6 +4111,9 @@ snapshots: '@esbuild/sunos-x64@0.28.0': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true @@ -3723,6 +4123,9 @@ snapshots: '@esbuild/win32-arm64@0.28.0': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true @@ -3732,6 +4135,9 @@ snapshots: '@esbuild/win32-ia32@0.28.0': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -4333,6 +4739,12 @@ snapshots: dependencies: undici-types: 7.24.6 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.9.1 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + '@types/resolve@1.20.2': {} '@types/unist@3.0.3': {} @@ -4380,7 +4792,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) + vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) '@vitest/expect@4.1.7': dependencies: @@ -4391,6 +4803,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + '@vitest/mocker@4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))': dependencies: '@vitest/spy': 4.1.7 @@ -4759,6 +5179,18 @@ snapshots: dotenv@17.4.2: {} + drizzle-kit@0.31.10: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.22.3 + + drizzle-orm@0.45.2(@types/pg@8.20.0)(pg@8.21.0): + optionalDependencies: + '@types/pg': 8.20.0 + pg: 8.21.0 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -4798,6 +5230,31 @@ snapshots: es-module-lexer@2.1.0: {} + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -4957,6 +5414,10 @@ snapshots: get-port-please@3.2.0: {} + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + giget@3.2.0: {} glob-parent@5.1.2: @@ -5576,6 +6037,41 @@ snapshots: perfect-debounce@2.1.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.13.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5600,6 +6096,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + powershell-utils@0.1.0: {} pretty-bytes@7.1.0: {} @@ -5670,6 +6176,8 @@ snapshots: resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -5837,6 +6345,8 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + srvx@0.9.8: {} stackback@0.0.2: {} @@ -6000,6 +6510,12 @@ snapshots: trim-lines@3.0.1: {} + tsx@4.22.3: + dependencies: + esbuild: 0.28.0 + optionalDependencies: + fsevents: 2.3.3 + turbo@2.9.14: optionalDependencies: '@turbo/darwin-64': 2.9.14 @@ -6183,10 +6699,55 @@ snapshots: lightningcss: 1.32.0 terser: 5.48.0 + vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.48.0 + tsx: 4.22.3 + vitefu@1.1.3(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): optionalDependencies: vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) + vitest@4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.1 + '@vitest/coverage-v8': 4.1.7(vitest@4.1.7) + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vitest@4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)): dependencies: '@vitest/expect': 4.1.7 @@ -6277,6 +6838,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/web/drizzle.config.ts b/web/drizzle.config.ts index a3dc60d..fa8b105 100644 --- a/web/drizzle.config.ts +++ b/web/drizzle.config.ts @@ -1,12 +1,10 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ - schema: "./src/server/db/schema.ts", + schema: "./src/server/db/schema/index.ts", out: "./drizzle", - dialect: "sqlite", - driver: "turso", + dialect: "postgresql", dbCredentials: { - url: process.env.DATABASE_URL!, - authToken: process.env.DATABASE_AUTH_TOKEN, + url: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai", }, }); diff --git a/web/package.json b/web/package.json index 6fddceb..cead89c 100644 --- a/web/package.json +++ b/web/package.json @@ -17,9 +17,13 @@ "@tailwindcss/vite": "^4.0.0", "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", + "@types/three": "^0.184.1", "@typeschema/valibot": "^0.13.4", + "drizzle-orm": "^0.45.2", + "pg": "^8.21.0", "solid-js": "^1.9.5", "tailwindcss": "^4.0.0", + "three": "^0.184.0", "valibot": "^0.29.0", "vite": "^7.0.0" }, @@ -27,6 +31,8 @@ "node": ">=22" }, "devDependencies": { + "@types/pg": "^8.20.0", + "drizzle-kit": "^0.31.10", "jsdom": "^29.1.1", "vite-plugin-solid": "^2.11.12", "vitest": "^4.1.5" diff --git a/web/src/server/db/index.ts b/web/src/server/db/index.ts index ee8c1da..6750c09 100644 --- a/web/src/server/db/index.ts +++ b/web/src/server/db/index.ts @@ -1,11 +1,10 @@ -import { createClient } from "@libsql/client"; -import { drizzle } from "drizzle-orm/libsql"; +import { drizzle } from "drizzle-orm/node-postgres"; +import pg from "pg"; import * as schema from "./schema"; -const client = createClient({ - url: process.env.DATABASE_URL ?? "file:local.db", - authToken: process.env.DATABASE_AUTH_TOKEN, +const pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai", }); -export const db = drizzle(client, { schema }); +export const db = drizzle(pool, { schema }); diff --git a/web/src/server/db/schema.test.ts b/web/src/server/db/schema.test.ts new file mode 100644 index 0000000..07565a2 --- /dev/null +++ b/web/src/server/db/schema.test.ts @@ -0,0 +1,442 @@ +import { describe, it, expect } from "vitest"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import * as schema from "./schema"; + +const tableNames = [ + "users", "accounts", "sessions", "deviceTokens", + "familyGroups", "familyGroupMembers", "subscriptions", + "watchlistItems", "exposures", + "alerts", + "voiceEnrollments", "voiceAnalyses", "analysisJobs", "analysisResults", + "spamFeedback", "spamRules", + "auditLogs", "kpiSnapshots", + "normalizedAlerts", "correlationGroups", + "securityReports", + "waitlistEntries", "blogPosts", + "propertyWatchlistItems", "propertySnapshots", "propertyChanges", + "infoBrokers", "removalRequests", "brokerListings", +]; + +const enumNames = [ + "userRole", "deviceType", "platform", "familyMemberRole", + "subscriptionTier", "subscriptionStatus", + "watchlistType", "exposureSource", "exposureSeverity", + "alertType", "alertSeverity", "alertChannel", + "detectionVerdict", "analysisType", "analysisJobStatus", + "feedbackType", "ruleType", "ruleAction", + "alertSource", "alertCategory", "normalizedAlertSeverity", "correlationStatus", + "reportType", "reportStatus", + "propertyChangeType", "propertyChangeSeverity", + "brokerCategory", "removalMethod", "removalStatus", +]; + +describe("schema exports", () => { + it("exports all 29 tables", () => { + for (const name of tableNames) { + expect((schema as Record)[name], `Missing table: ${name}`).toBeDefined(); + } + }); + + it("exports all 28 enums", () => { + for (const name of enumNames) { + expect((schema as Record)[name], `Missing enum: ${name}`).toBeDefined(); + } + }); + + it("exports all 25 relation definitions", () => { + const relationNames = [ + "usersRelations", "accountsRelations", "sessionsRelations", "deviceTokensRelations", + "familyGroupsRelations", "familyGroupMembersRelations", "subscriptionsRelations", + "watchlistItemsRelations", "exposuresRelations", + "alertsRelations", + "voiceEnrollmentsRelations", "voiceAnalysesRelations", "analysisJobsRelations", "analysisResultsRelations", + "spamFeedbackRelations", "spamRulesRelations", + "normalizedAlertsRelations", "correlationGroupsRelations", + "securityReportsRelations", + "propertyWatchlistItemsRelations", "propertySnapshotsRelations", "propertyChangesRelations", + "infoBrokersRelations", "removalRequestsRelations", "brokerListingsRelations", + ]; + for (const name of relationNames) { + expect((schema as Record)[name], `Missing relation: ${name}`).toBeDefined(); + } + }); +}); + +describe("users table", () => { + const config = getTableConfig(schema.users); + + it("has expected columns", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("id"); + expect(colNames).toContain("email"); + expect(colNames).toContain("email_verified"); + expect(colNames).toContain("name"); + expect(colNames).toContain("image"); + expect(colNames).toContain("role"); + expect(colNames).toContain("created_at"); + expect(colNames).toContain("updated_at"); + }); + + it("has 8 columns", () => { + expect(config.columns).toHaveLength(8); + }); + + it("has 2 indexes", () => { + expect(config.indexes.length).toBe(2); + }); +}); + +describe("accounts table", () => { + const config = getTableConfig(schema.accounts); + + it("has user_id, provider, provider_account_id", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("user_id"); + expect(colNames).toContain("provider"); + expect(colNames).toContain("provider_account_id"); + }); + + it("has expected columns with correct count", () => { + expect(config.columns.length).toBeGreaterThanOrEqual(9); + }); +}); + +describe("subscriptions table", () => { + const config = getTableConfig(schema.subscriptions); + + it("has expected columns with correct count", () => { + expect(config.columns.length).toBeGreaterThanOrEqual(11); + }); + + it("has stripe_id, tier, status columns", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("stripe_id"); + expect(colNames).toContain("tier"); + expect(colNames).toContain("status"); + expect(colNames).toContain("current_period_start"); + expect(colNames).toContain("current_period_end"); + }); +}); + +describe("alerts table", () => { + const config = getTableConfig(schema.alerts); + + it("has foreign key columns", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("subscription_id"); + expect(colNames).toContain("user_id"); + expect(colNames).toContain("exposure_id"); + }); + + it("has channel array column", () => { + const channelCol = config.columns.find((c) => c.name === "channel"); + expect(channelCol).toBeDefined(); + }); +}); + +describe("blogPosts table", () => { + const config = getTableConfig(schema.blogPosts); + + it("has tags array column", () => { + const tagsCol = config.columns.find((c) => c.name === "tags"); + expect(tagsCol).toBeDefined(); + }); + + it("has all expected columns", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("id"); + expect(colNames).toContain("slug"); + expect(colNames).toContain("title"); + expect(colNames).toContain("content"); + expect(colNames).toContain("tags"); + expect(colNames).toContain("published"); + expect(colNames).toContain("published_at"); + expect(colNames).toContain("view_count"); + }); +}); + +describe("normalizedAlerts table", () => { + const config = getTableConfig(schema.normalizedAlerts); + + it("has source_alert_id column", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("source_alert_id"); + }); + + it("has entities column", () => { + expect(config.columns.find((c) => c.name === "entities")).toBeDefined(); + }); +}); + +describe("watchlistItems table", () => { + const config = getTableConfig(schema.watchlistItems); + + it("has all expected columns", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("subscription_id"); + expect(colNames).toContain("type"); + expect(colNames).toContain("value"); + expect(colNames).toContain("hash"); + expect(colNames).toContain("is_active"); + }); +}); + +describe("exposures table", () => { + const config = getTableConfig(schema.exposures); + + it("has metadata jsonb, severity, detected_at", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("metadata"); + expect(colNames).toContain("severity"); + expect(colNames).toContain("detected_at"); + expect(colNames).toContain("is_first_time"); + }); +}); + +describe("voiceEnrollments table", () => { + const config = getTableConfig(schema.voiceEnrollments); + + it("has voice_hash and audio_metadata", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("voice_hash"); + expect(colNames).toContain("audio_metadata"); + expect(colNames).toContain("is_active"); + }); +}); + +describe("analysisJobs table", () => { + const config = getTableConfig(schema.analysisJobs); + + it("has analysis_type, status, error_message", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("analysis_type"); + expect(colNames).toContain("status"); + expect(colNames).toContain("error_message"); + expect(colNames).toContain("audio_file_path"); + }); +}); + +describe("analysisResults table", () => { + const config = getTableConfig(schema.analysisResults); + + it("has analysis_job_id with unique", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("analysis_job_id"); + expect(colNames).toContain("synthetic_score"); + expect(colNames).toContain("verdict"); + expect(colNames).toContain("processing_time_ms"); + }); +}); + +describe("spamFeedback table", () => { + const config = getTableConfig(schema.spamFeedback); + + it("has phone_number, phone_number_hash, is_spam", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("phone_number"); + expect(colNames).toContain("phone_number_hash"); + expect(colNames).toContain("is_spam"); + expect(colNames).toContain("feedback_type"); + }); +}); + +describe("spamRules table", () => { + const config = getTableConfig(schema.spamRules); + + it("has rule_type, pattern, action", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("rule_type"); + expect(colNames).toContain("pattern"); + expect(colNames).toContain("action"); + expect(colNames).toContain("is_global"); + }); +}); + +describe("auditLogs table", () => { + const config = getTableConfig(schema.auditLogs); + + it("has action, resource, resource_id", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("action"); + expect(colNames).toContain("resource"); + expect(colNames).toContain("resource_id"); + expect(colNames).toContain("ip_address"); + expect(colNames).toContain("user_agent"); + }); +}); + +describe("kpiSnapshots table", () => { + const config = getTableConfig(schema.kpiSnapshots); + + it("has date, metric_name, metric_value", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("date"); + expect(colNames).toContain("metric_name"); + expect(colNames).toContain("metric_value"); + }); +}); + +describe("propertyWatchlistItems table", () => { + const config = getTableConfig(schema.propertyWatchlistItems); + + it("has address, street_address, parcel_id", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("address"); + expect(colNames).toContain("street_address"); + expect(colNames).toContain("parcel_id"); + expect(colNames).toContain("latitude"); + expect(colNames).toContain("longitude"); + }); +}); + +describe("propertySnapshots table", () => { + const config = getTableConfig(schema.propertySnapshots); + + it("has captured_at, owner_name, address jsonb", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("captured_at"); + expect(colNames).toContain("owner_name"); + expect(colNames).toContain("address"); + expect(colNames).toContain("property_type"); + expect(colNames).toContain("tax_amount"); + expect(colNames).toContain("lien_count"); + }); +}); + +describe("propertyChanges table", () => { + const config = getTableConfig(schema.propertyChanges); + + it("has change_type, severity, details", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("change_type"); + expect(colNames).toContain("severity"); + expect(colNames).toContain("details"); + expect(colNames).toContain("detected_at"); + }); +}); + +describe("infoBrokers table", () => { + const config = getTableConfig(schema.infoBrokers); + + it("has name, domain, category", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("name"); + expect(colNames).toContain("domain"); + expect(colNames).toContain("category"); + expect(colNames).toContain("removal_method"); + expect(colNames).toContain("estimated_days"); + }); +}); + +describe("removalRequests table", () => { + const config = getTableConfig(schema.removalRequests); + + it("has personal_info jsonb, status, attempts", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("personal_info"); + expect(colNames).toContain("status"); + expect(colNames).toContain("attempts"); + expect(colNames).toContain("next_retry_at"); + expect(colNames).toContain("error"); + expect(colNames).toContain("notes"); + }); +}); + +describe("brokerListings table", () => { + const config = getTableConfig(schema.brokerListings); + + it("has url, data_found, screenshot_url", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("url"); + expect(colNames).toContain("data_found"); + expect(colNames).toContain("screenshot_url"); + expect(colNames).toContain("is_removed"); + }); +}); + +describe("correlationGroups table", () => { + const config = getTableConfig(schema.correlationGroups); + + it("has entities, highest_severity, alert_count", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("entities"); + expect(colNames).toContain("highest_severity"); + expect(colNames).toContain("alert_count"); + expect(colNames).toContain("summary"); + }); +}); + +describe("securityReports table", () => { + const config = getTableConfig(schema.securityReports); + + it("has period_start, period_end, pdf_url", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("period_start"); + expect(colNames).toContain("period_end"); + expect(colNames).toContain("pdf_url"); + expect(colNames).toContain("html_content"); + expect(colNames).toContain("report_type"); + expect(colNames).toContain("status"); + }); +}); + +describe("waitlistEntries table", () => { + const config = getTableConfig(schema.waitlistEntries); + + it("has email, source, utm tracking, conversion tracking", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("email"); + expect(colNames).toContain("source"); + expect(colNames).toContain("utm_source"); + expect(colNames).toContain("utm_medium"); + expect(colNames).toContain("utm_campaign"); + expect(colNames).toContain("converted_at"); + expect(colNames).toContain("converted_to_user_id"); + }); +}); + +describe("familyGroups table", () => { + const config = getTableConfig(schema.familyGroups); + + it("has owner_id, name", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("owner_id"); + expect(colNames).toContain("name"); + }); +}); + +describe("familyGroupMembers table", () => { + const config = getTableConfig(schema.familyGroupMembers); + + it("has group_id, user_id, role, joined_at", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("group_id"); + expect(colNames).toContain("user_id"); + expect(colNames).toContain("role"); + expect(colNames).toContain("joined_at"); + }); +}); + +describe("sessions table", () => { + const config = getTableConfig(schema.sessions); + + it("has session_token, expires", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("session_token"); + expect(colNames).toContain("expires"); + expect(colNames).toContain("user_id"); + }); +}); + +describe("deviceTokens table", () => { + const config = getTableConfig(schema.deviceTokens); + + it("has device_type, platform, token, is_active", () => { + const colNames = config.columns.map((c) => c.name); + expect(colNames).toContain("device_type"); + expect(colNames).toContain("platform"); + expect(colNames).toContain("token"); + expect(colNames).toContain("is_active"); + expect(colNames).toContain("last_used_at"); + }); +}); diff --git a/web/src/server/db/schema.ts b/web/src/server/db/schema.ts index 4d820e1..1b1795d 100644 --- a/web/src/server/db/schema.ts +++ b/web/src/server/db/schema.ts @@ -1,10 +1 @@ -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; - -export const users = sqliteTable("users", { - id: integer("id").primaryKey({ autoIncrement: true }), - name: text("name").notNull(), - email: text("email").notNull().unique(), - createdAt: integer("created_at", { mode: "timestamp" }) - .notNull() - .$defaultFn(() => new Date()), -}); +export * from "./schema/index"; diff --git a/web/src/server/db/schema/alerts.ts b/web/src/server/db/schema/alerts.ts new file mode 100644 index 0000000..b2038c5 --- /dev/null +++ b/web/src/server/db/schema/alerts.ts @@ -0,0 +1,26 @@ +import { pgTable, text, timestamp, index, uuid, boolean } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { subscriptions } from "./subscription"; +import { exposures } from "./darkwatch"; +import { alertType, alertSeverity, alertChannel } from "./enums"; + +export const alerts = pgTable("alerts", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + exposureId: uuid("exposure_id").references(() => exposures.id), + type: alertType("type").notNull(), + title: text("title").notNull(), + message: text("message").notNull(), + severity: alertSeverity("severity").default("info").notNull(), + isRead: boolean("is_read").default(false).notNull(), + readAt: timestamp("read_at", { withTimezone: true, mode: "date" }), + channel: alertChannel("channel").array().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subscriptionIdIdx: index("alerts_subscription_id_idx").on(table.subscriptionId), + userIdIdx: index("alerts_user_id_idx").on(table.userId), + isReadIdx: index("alerts_is_read_idx").on(table.isRead), + createdAtIdx: index("alerts_created_at_idx").on(table.createdAt), +})); diff --git a/web/src/server/db/schema/audit.ts b/web/src/server/db/schema/audit.ts new file mode 100644 index 0000000..02a87cb --- /dev/null +++ b/web/src/server/db/schema/audit.ts @@ -0,0 +1,31 @@ +import { pgTable, text, timestamp, index, uuid, jsonb, doublePrecision } from "drizzle-orm/pg-core"; + +export const auditLogs = pgTable("audit_logs", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id"), + action: text("action").notNull(), + resource: text("resource").notNull(), + resourceId: text("resource_id"), + changes: jsonb("changes"), + metadata: jsonb("metadata"), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdIdx: index("audit_logs_user_id_idx").on(table.userId), + actionIdx: index("audit_logs_action_idx").on(table.action), + resourceIdx: index("audit_logs_resource_idx").on(table.resource), + createdAtIdx: index("audit_logs_created_at_idx").on(table.createdAt), +})); + +export const kpiSnapshots = pgTable("kpi_snapshots", { + id: uuid("id").defaultRandom().primaryKey(), + date: timestamp("date", { withTimezone: true, mode: "date" }).notNull().unique(), + metricName: text("metric_name").notNull(), + metricValue: doublePrecision("metric_value").notNull(), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + metricNameIdx: index("kpi_snapshots_metric_name_idx").on(table.metricName), + dateIdx: index("kpi_snapshots_date_idx").on(table.date), +})); diff --git a/web/src/server/db/schema/auth.ts b/web/src/server/db/schema/auth.ts new file mode 100644 index 0000000..3d040e8 --- /dev/null +++ b/web/src/server/db/schema/auth.ts @@ -0,0 +1,66 @@ +import { pgTable, text, timestamp, uniqueIndex, index, uuid, integer, boolean } from "drizzle-orm/pg-core"; +import { userRole, deviceType, platform } from "./enums"; + +export const users = pgTable("users", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull().unique(), + emailVerified: timestamp("email_verified", { withTimezone: true, mode: "date" }), + name: text("name"), + image: text("image"), + role: userRole("role").default("user").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + emailIdx: index("users_email_idx").on(table.email), + roleIdx: index("users_role_idx").on(table.role), +})); + +export const accounts = pgTable("accounts", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + providerAccountId: text("provider_account_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + expiresAt: integer("expires_at"), + tokenType: text("token_type"), + scope: text("scope"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userProviderUnique: uniqueIndex("accounts_user_provider_unique").on(table.userId, table.provider, table.providerAccountId), + userIdIdx: index("accounts_user_id_idx").on(table.userId), +})); + +export const sessions = pgTable("sessions", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + sessionToken: text("session_token").notNull().unique(), + expires: timestamp("expires", { withTimezone: true, mode: "date" }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + sessionTokenIdx: index("sessions_session_token_idx").on(table.sessionToken), + userIdIdx: index("sessions_user_id_idx").on(table.userId), +})); + +export const deviceTokens = pgTable("device_tokens", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + deviceType: deviceType("device_type").notNull(), + token: text("token").notNull().unique(), + platform: platform("platform").notNull(), + appName: text("app_name"), + appVersion: text("app_version"), + osVersion: text("os_version"), + model: text("model"), + isActive: boolean("is_active").default(true).notNull(), + lastUsedAt: timestamp("last_used_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("device_tokens_user_id_idx").on(table.userId), + deviceTypeIdx: index("device_tokens_device_type_idx").on(table.deviceType), + platformIdx: index("device_tokens_platform_idx").on(table.platform), + isActiveIdx: index("device_tokens_is_active_idx").on(table.isActive), +})); diff --git a/web/src/server/db/schema/correlation.ts b/web/src/server/db/schema/correlation.ts new file mode 100644 index 0000000..e7d2724 --- /dev/null +++ b/web/src/server/db/schema/correlation.ts @@ -0,0 +1,45 @@ +import { pgTable, text, timestamp, uniqueIndex, index, uuid, jsonb, integer } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { alertSource, alertCategory, normalizedAlertSeverity, correlationStatus } from "./enums"; + +export const correlationGroups = pgTable("correlation_groups", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + entities: jsonb("entities").notNull(), + highestSeverity: normalizedAlertSeverity("highest_severity").notNull(), + status: correlationStatus("status").default("ACTIVE").notNull(), + alertCount: integer("alert_count").default(0).notNull(), + summary: text("summary"), + resolvedAt: timestamp("resolved_at", { withTimezone: true, mode: "date" }), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("correlation_groups_user_id_idx").on(table.userId), + statusIdx: index("correlation_groups_status_idx").on(table.status), + userIdStatusIdx: index("correlation_groups_user_id_status_idx").on(table.userId, table.status), + createdAtIdx: index("correlation_groups_created_at_idx").on(table.createdAt), +})); + +export const normalizedAlerts = pgTable("normalized_alerts", { + id: uuid("id").defaultRandom().primaryKey(), + source: alertSource("source").notNull(), + category: alertCategory("category").notNull(), + severity: normalizedAlertSeverity("severity").notNull(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + description: text("description").notNull(), + entities: jsonb("entities").notNull(), + sourceAlertId: text("source_alert_id").notNull(), + groupId: uuid("group_id").references(() => correlationGroups.id), + payload: jsonb("payload"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + sourceAlertIdUnique: uniqueIndex("normalized_alerts_source_alert_id_unique").on(table.sourceAlertId), + userIdIdx: index("normalized_alerts_user_id_idx").on(table.userId), + groupIdIdx: index("normalized_alerts_group_id_idx").on(table.groupId), + sourceIdx: index("normalized_alerts_source_idx").on(table.source), + severityIdx: index("normalized_alerts_severity_idx").on(table.severity), + createdAtIdx: index("normalized_alerts_created_at_idx").on(table.createdAt), + userIdCreatedAtIdx: index("normalized_alerts_user_id_created_at_idx").on(table.userId, table.createdAt), +})); diff --git a/web/src/server/db/schema/darkwatch.ts b/web/src/server/db/schema/darkwatch.ts new file mode 100644 index 0000000..d6fb622 --- /dev/null +++ b/web/src/server/db/schema/darkwatch.ts @@ -0,0 +1,41 @@ +import { pgTable, text, timestamp, uniqueIndex, index, uuid, boolean, jsonb } from "drizzle-orm/pg-core"; +import { subscriptions } from "./subscription"; +import { watchlistType, exposureSource, exposureSeverity } from "./enums"; + +export const watchlistItems = pgTable("watchlist_items", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + type: watchlistType("type").notNull(), + value: text("value").notNull(), + hash: text("hash").notNull(), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subTypeHashUnique: uniqueIndex("watchlist_items_sub_type_hash_unique").on(table.subscriptionId, table.type, table.hash), + subscriptionIdIdx: index("watchlist_items_subscription_id_idx").on(table.subscriptionId), + typeIdx: index("watchlist_items_type_idx").on(table.type), + hashIdx: index("watchlist_items_hash_idx").on(table.hash), +})); + +export const exposures = pgTable("exposures", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + watchlistItemId: uuid("watchlist_item_id").references(() => watchlistItems.id), + source: exposureSource("source").notNull(), + dataType: watchlistType("data_type").notNull(), + identifier: text("identifier").notNull(), + identifierHash: text("identifier_hash").notNull(), + severity: exposureSeverity("severity").default("info").notNull(), + metadata: jsonb("metadata"), + isFirstTime: boolean("is_first_time").default(false).notNull(), + detectedAt: timestamp("detected_at", { withTimezone: true, mode: "date" }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subscriptionIdIdx: index("exposures_subscription_id_idx").on(table.subscriptionId), + watchlistItemIdIdx: index("exposures_watchlist_item_id_idx").on(table.watchlistItemId), + sourceIdx: index("exposures_source_idx").on(table.source), + severityIdx: index("exposures_severity_idx").on(table.severity), + detectedAtIdx: index("exposures_detected_at_idx").on(table.detectedAt), +})); diff --git a/web/src/server/db/schema/enums.ts b/web/src/server/db/schema/enums.ts new file mode 100644 index 0000000..2dec567 --- /dev/null +++ b/web/src/server/db/schema/enums.ts @@ -0,0 +1,31 @@ +import { pgEnum } from "drizzle-orm/pg-core"; + +export const userRole = pgEnum("user_role", ["user", "family_admin", "family_member", "support"]); +export const deviceType = pgEnum("device_type", ["mobile", "web", "desktop"]); +export const platform = pgEnum("platform", ["ios", "android", "web"]); +export const familyMemberRole = pgEnum("family_member_role", ["owner", "admin", "member"]); +export const subscriptionTier = pgEnum("subscription_tier", ["basic", "plus", "premium"]); +export const subscriptionStatus = pgEnum("subscription_status", ["active", "past_due", "canceled", "unpaid", "trialing"]); +export const watchlistType = pgEnum("watchlist_type", ["email", "phoneNumber", "ssn", "address", "domain"]); +export const exposureSource = pgEnum("exposure_source", ["hibp", "securityTrails", "censys", "darkWebForum", "shodan", "honeypot"]); +export const exposureSeverity = pgEnum("exposure_severity", ["info", "warning", "critical"]); +export const alertType = pgEnum("alert_type", ["exposure_detected", "exposure_resolved", "scan_complete", "subscription_changed", "system_warning"]); +export const alertSeverity = pgEnum("alert_severity", ["info", "warning", "critical"]); +export const alertChannel = pgEnum("alert_channel", ["email", "push", "sms"]); +export const detectionVerdict = pgEnum("detection_verdict", ["NATURAL", "SYNTHETIC", "UNCERTAIN"]); +export const analysisType = pgEnum("analysis_type", ["SYNTHETIC_DETECTION", "VOICE_MATCH", "BATCH"]); +export const analysisJobStatus = pgEnum("analysis_job_status", ["PENDING", "RUNNING", "COMPLETED", "FAILED"]); +export const feedbackType = pgEnum("feedback_type", ["initial_detection", "user_confirmation", "user_rejection", "auto_learned"]); +export const ruleType = pgEnum("rule_type", ["phoneNumber", "areaCode", "prefix", "pattern", "reputation"]); +export const ruleAction = pgEnum("rule_action", ["block", "flag", "allow", "challenge"]); +export const alertSource = pgEnum("alert_source", ["DARKWATCH", "SPAMSHIELD", "VOICEPRINT", "CALL_ANALYSIS", "HOME_TITLE", "INFO_BROKER"]); +export const alertCategory = pgEnum("alert_category", ["BREACH_EXPOSURE", "SPAM_CALL", "SPAM_SMS", "SYNTHETIC_VOICE", "VOICE_MISMATCH", "CALL_ANOMALY", "CALL_QUALITY", "CALL_EVENT", "HOME_TITLE", "INFO_BROKER_LISTING", "INFO_BROKER_REMOVAL"]); +export const normalizedAlertSeverity = pgEnum("normalized_alert_severity", ["LOW", "INFO", "MEDIUM", "WARNING", "HIGH", "CRITICAL"]); +export const correlationStatus = pgEnum("correlation_status", ["ACTIVE", "RESOLVED", "FALSE_POSITIVE"]); +export const reportType = pgEnum("report_type", ["MONTHLY_PLUS", "ANNUAL_PREMIUM", "WEEKLY_DIGEST"]); +export const reportStatus = pgEnum("report_status", ["PENDING", "GENERATING", "COMPLETED", "FAILED", "DELIVERED"]); +export const propertyChangeType = pgEnum("property_change_type", ["tax_change", "deed_change", "ownership_transfer", "lien_filing", "metadata_change"]); +export const propertyChangeSeverity = pgEnum("property_change_severity", ["info", "warning", "critical"]); +export const brokerCategory = pgEnum("broker_category", ["PEOPLE_SEARCH", "BACKGROUND_CHECK", "PUBLIC_RECORDS", "REVERSE_LOOKUP", "SOCIAL_MEDIA"]); +export const removalMethod = pgEnum("removal_method", ["AUTOMATED", "MANUAL_FORM", "EMAIL", "PHONE", "MAIL", "NONE"]); +export const removalStatus = pgEnum("removal_status", ["PENDING", "SUBMITTED", "IN_PROGRESS", "COMPLETED", "FAILED", "REJECTED", "CANCELLED"]); diff --git a/web/src/server/db/schema/hometitle.ts b/web/src/server/db/schema/hometitle.ts new file mode 100644 index 0000000..4ebdd2d --- /dev/null +++ b/web/src/server/db/schema/hometitle.ts @@ -0,0 +1,59 @@ +import { pgTable, text, timestamp, uniqueIndex, index, uuid, boolean, jsonb, doublePrecision, integer } from "drizzle-orm/pg-core"; +import { subscriptions } from "./subscription"; +import { propertyChangeType, propertyChangeSeverity } from "./enums"; + +export const propertyWatchlistItems = pgTable("property_watchlist_items", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + address: text("address").notNull(), + parcelId: text("parcel_id"), + ownerName: text("owner_name"), + streetAddress: text("street_address").notNull(), + city: text("city").default(""), + state: text("state").default(""), + zipCode: text("zip_code").default(""), + latitude: doublePrecision("latitude"), + longitude: doublePrecision("longitude"), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subParcelIdUnique: uniqueIndex("property_watchlist_items_sub_parcel_unique").on(table.subscriptionId, table.parcelId), + subscriptionIdIdx: index("property_watchlist_items_subscription_id_idx").on(table.subscriptionId), + parcelIdIdx: index("property_watchlist_items_parcel_id_idx").on(table.parcelId), + addressIdx: index("property_watchlist_items_address_idx").on(table.address), +})); + +export const propertySnapshots = pgTable("property_snapshots", { + id: uuid("id").defaultRandom().primaryKey(), + propertyWatchlistItemId: uuid("property_watchlist_item_id").notNull().references(() => propertyWatchlistItems.id, { onDelete: "cascade" }), + subscriptionId: uuid("subscription_id").notNull(), + capturedAt: timestamp("captured_at", { withTimezone: true, mode: "date" }).notNull(), + ownerName: text("owner_name").notNull(), + address: jsonb("address").notNull(), + deedDate: text("deed_date"), + taxId: text("tax_id"), + propertyType: text("property_type").default("residential").notNull(), + taxAmount: doublePrecision("tax_amount"), + lienCount: integer("lien_count").default(0).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + propertyWatchlistItemIdIdx: index("property_snapshots_property_watchlist_item_id_idx").on(table.propertyWatchlistItemId), + subscriptionIdIdx: index("property_snapshots_subscription_id_idx").on(table.subscriptionId), + capturedAtIdx: index("property_snapshots_captured_at_idx").on(table.capturedAt), +})); + +export const propertyChanges = pgTable("property_changes", { + id: uuid("id").defaultRandom().primaryKey(), + propertyWatchlistItemId: uuid("property_watchlist_item_id").notNull().references(() => propertyWatchlistItems.id, { onDelete: "cascade" }), + snapshotId: uuid("snapshot_id").references(() => propertySnapshots.id), + changeType: propertyChangeType("change_type").notNull(), + severity: propertyChangeSeverity("severity").default("info").notNull(), + details: jsonb("details"), + detectedAt: timestamp("detected_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + propertyWatchlistItemIdIdx: index("property_changes_property_watchlist_item_id_idx").on(table.propertyWatchlistItemId), + snapshotIdIdx: index("property_changes_snapshot_id_idx").on(table.snapshotId), + changeTypeIdx: index("property_changes_change_type_idx").on(table.changeType), +})); diff --git a/web/src/server/db/schema/index.ts b/web/src/server/db/schema/index.ts new file mode 100644 index 0000000..424a58c --- /dev/null +++ b/web/src/server/db/schema/index.ts @@ -0,0 +1,14 @@ +export * from "./enums"; +export * from "./auth"; +export * from "./subscription"; +export * from "./darkwatch"; +export * from "./alerts"; +export * from "./voiceprint"; +export * from "./spamshield"; +export * from "./audit"; +export * from "./correlation"; +export * from "./reports"; +export * from "./marketing"; +export * from "./hometitle"; +export * from "./removebrokers"; +export * from "./relations"; diff --git a/web/src/server/db/schema/marketing.ts b/web/src/server/db/schema/marketing.ts new file mode 100644 index 0000000..ec96889 --- /dev/null +++ b/web/src/server/db/schema/marketing.ts @@ -0,0 +1,43 @@ +import { pgTable, text, timestamp, index, uuid, boolean, integer, jsonb } from "drizzle-orm/pg-core"; +import { subscriptionTier } from "./enums"; + +export const waitlistEntries = pgTable("waitlist_entries", { + id: uuid("id").defaultRandom().primaryKey(), + email: text("email").notNull(), + name: text("name"), + source: text("source"), + tier: subscriptionTier("tier"), + utmSource: text("utm_source"), + utmMedium: text("utm_medium"), + utmCampaign: text("utm_campaign"), + metadata: jsonb("metadata"), + convertedAt: timestamp("converted_at", { withTimezone: true, mode: "date" }), + convertedToUserId: text("converted_to_user_id"), + convertedToSubscriptionId: text("converted_to_subscription_id"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + emailIdx: index("waitlist_entries_email_idx").on(table.email), + sourceIdx: index("waitlist_entries_source_idx").on(table.source), + createdAtIdx: index("waitlist_entries_created_at_idx").on(table.createdAt), +})); + +export const blogPosts = pgTable("blog_posts", { + id: uuid("id").defaultRandom().primaryKey(), + slug: text("slug").notNull().unique(), + title: text("title").notNull(), + excerpt: text("excerpt"), + content: text("content").notNull(), + authorName: text("author_name"), + coverImageUrl: text("cover_image_url"), + tags: text("tags").array().notNull(), + published: boolean("published").default(false).notNull(), + publishedAt: timestamp("published_at", { withTimezone: true, mode: "date" }), + viewCount: integer("view_count").default(0).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + slugIdx: index("blog_posts_slug_idx").on(table.slug), + publishedIdx: index("blog_posts_published_idx").on(table.published, table.publishedAt), + tagsIdx: index("blog_posts_tags_idx").on(table.tags), +})); diff --git a/web/src/server/db/schema/relations.ts b/web/src/server/db/schema/relations.ts new file mode 100644 index 0000000..d5f0048 --- /dev/null +++ b/web/src/server/db/schema/relations.ts @@ -0,0 +1,156 @@ +import { relations } from "drizzle-orm"; + +import { users } from "./auth"; +import { accounts } from "./auth"; +import { sessions } from "./auth"; +import { deviceTokens } from "./auth"; +import { familyGroups, familyGroupMembers, subscriptions } from "./subscription"; +import { watchlistItems, exposures } from "./darkwatch"; +import { alerts } from "./alerts"; +import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "./voiceprint"; +import { spamFeedback, spamRules } from "./spamshield"; +import { normalizedAlerts, correlationGroups } from "./correlation"; +import { securityReports } from "./reports"; +import { propertyWatchlistItems, propertySnapshots, propertyChanges } from "./hometitle"; +import { infoBrokers, removalRequests, brokerListings } from "./removebrokers"; + +export const usersRelations = relations(users, ({ many }) => ({ + accounts: many(accounts), + sessions: many(sessions), + deviceTokens: many(deviceTokens), + familyGroups: many(familyGroupMembers), + familyGroupOwned: many(familyGroups), + subscriptions: many(subscriptions), + alerts: many(alerts), + voiceEnrollments: many(voiceEnrollments), + voiceAnalyses: many(voiceAnalyses), + spamFeedback: many(spamFeedback), + spamRules: many(spamRules), + normalizedAlerts: many(normalizedAlerts), + correlationGroups: many(correlationGroups), + securityReports: many(securityReports), + analysisJobs: many(analysisJobs), +})); + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { fields: [accounts.userId], references: [users.id] }), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }), +})); + +export const deviceTokensRelations = relations(deviceTokens, ({ one }) => ({ + user: one(users, { fields: [deviceTokens.userId], references: [users.id] }), +})); + +export const familyGroupsRelations = relations(familyGroups, ({ one, many }) => ({ + owner: one(users, { fields: [familyGroups.ownerId], references: [users.id] }), + members: many(familyGroupMembers), + subscriptions: many(subscriptions), +})); + +export const familyGroupMembersRelations = relations(familyGroupMembers, ({ one }) => ({ + group: one(familyGroups, { fields: [familyGroupMembers.groupId], references: [familyGroups.id] }), + user: one(users, { fields: [familyGroupMembers.userId], references: [users.id] }), +})); + +export const subscriptionsRelations = relations(subscriptions, ({ one, many }) => ({ + user: one(users, { fields: [subscriptions.userId], references: [users.id] }), + familyGroup: one(familyGroups, { fields: [subscriptions.familyGroupId], references: [familyGroups.id] }), + watchlistItems: many(watchlistItems), + exposures: many(exposures), + alerts: many(alerts), + propertyWatchlistItems: many(propertyWatchlistItems), + removalRequests: many(removalRequests), + brokerListings: many(brokerListings), +})); + +export const watchlistItemsRelations = relations(watchlistItems, ({ one, many }) => ({ + subscription: one(subscriptions, { fields: [watchlistItems.subscriptionId], references: [subscriptions.id] }), + exposures: many(exposures), +})); + +export const exposuresRelations = relations(exposures, ({ one, many }) => ({ + subscription: one(subscriptions, { fields: [exposures.subscriptionId], references: [subscriptions.id] }), + watchlistItem: one(watchlistItems, { fields: [exposures.watchlistItemId], references: [watchlistItems.id] }), + alerts: many(alerts), +})); + +export const alertsRelations = relations(alerts, ({ one }) => ({ + subscription: one(subscriptions, { fields: [alerts.subscriptionId], references: [subscriptions.id] }), + user: one(users, { fields: [alerts.userId], references: [users.id] }), + exposure: one(exposures, { fields: [alerts.exposureId], references: [exposures.id] }), +})); + +export const voiceEnrollmentsRelations = relations(voiceEnrollments, ({ one, many }) => ({ + user: one(users, { fields: [voiceEnrollments.userId], references: [users.id] }), + analyses: many(voiceAnalyses), +})); + +export const voiceAnalysesRelations = relations(voiceAnalyses, ({ one }) => ({ + enrollment: one(voiceEnrollments, { fields: [voiceAnalyses.enrollmentId], references: [voiceEnrollments.id] }), + user: one(users, { fields: [voiceAnalyses.userId], references: [users.id] }), +})); + +export const analysisJobsRelations = relations(analysisJobs, ({ one }) => ({ + user: one(users, { fields: [analysisJobs.userId], references: [users.id] }), + result: one(analysisResults), +})); + +export const analysisResultsRelations = relations(analysisResults, ({ one }) => ({ + analysisJob: one(analysisJobs, { fields: [analysisResults.analysisJobId], references: [analysisJobs.id] }), +})); + +export const spamFeedbackRelations = relations(spamFeedback, ({ one }) => ({ + user: one(users, { fields: [spamFeedback.userId], references: [users.id] }), +})); + +export const spamRulesRelations = relations(spamRules, ({ one }) => ({ + user: one(users, { fields: [spamRules.userId], references: [users.id] }), +})); + +export const normalizedAlertsRelations = relations(normalizedAlerts, ({ one }) => ({ + correlationGroup: one(correlationGroups, { fields: [normalizedAlerts.groupId], references: [correlationGroups.id] }), + user: one(users, { fields: [normalizedAlerts.userId], references: [users.id] }), +})); + +export const correlationGroupsRelations = relations(correlationGroups, ({ one, many }) => ({ + user: one(users, { fields: [correlationGroups.userId], references: [users.id] }), + alerts: many(normalizedAlerts), +})); + +export const securityReportsRelations = relations(securityReports, ({ one }) => ({ + user: one(users, { fields: [securityReports.userId], references: [users.id] }), +})); + +export const propertyWatchlistItemsRelations = relations(propertyWatchlistItems, ({ one, many }) => ({ + subscription: one(subscriptions, { fields: [propertyWatchlistItems.subscriptionId], references: [subscriptions.id] }), + snapshots: many(propertySnapshots), + changes: many(propertyChanges), +})); + +export const propertySnapshotsRelations = relations(propertySnapshots, ({ one, many }) => ({ + propertyWatchlistItem: one(propertyWatchlistItems, { fields: [propertySnapshots.propertyWatchlistItemId], references: [propertyWatchlistItems.id] }), + changes: many(propertyChanges), +})); + +export const propertyChangesRelations = relations(propertyChanges, ({ one }) => ({ + propertyWatchlistItem: one(propertyWatchlistItems, { fields: [propertyChanges.propertyWatchlistItemId], references: [propertyWatchlistItems.id] }), + snapshot: one(propertySnapshots, { fields: [propertyChanges.snapshotId], references: [propertySnapshots.id] }), +})); + +export const infoBrokersRelations = relations(infoBrokers, ({ many }) => ({ + removalRequests: many(removalRequests), +})); + +export const removalRequestsRelations = relations(removalRequests, ({ one, many }) => ({ + broker: one(infoBrokers, { fields: [removalRequests.brokerId], references: [infoBrokers.id] }), + subscription: one(subscriptions, { fields: [removalRequests.subscriptionId], references: [subscriptions.id] }), + brokerListings: many(brokerListings), +})); + +export const brokerListingsRelations = relations(brokerListings, ({ one }) => ({ + removalRequest: one(removalRequests, { fields: [brokerListings.removalRequestId], references: [removalRequests.id] }), + subscription: one(subscriptions, { fields: [brokerListings.subscriptionId], references: [subscriptions.id] }), +})); diff --git a/web/src/server/db/schema/removebrokers.ts b/web/src/server/db/schema/removebrokers.ts new file mode 100644 index 0000000..fddf163 --- /dev/null +++ b/web/src/server/db/schema/removebrokers.ts @@ -0,0 +1,67 @@ +import { pgTable, text, timestamp, index, uuid, boolean, jsonb, integer } from "drizzle-orm/pg-core"; +import { subscriptions } from "./subscription"; +import { brokerCategory, removalMethod, removalStatus } from "./enums"; + +export const infoBrokers = pgTable("info_brokers", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull(), + domain: text("domain").notNull().unique(), + category: brokerCategory("category").notNull(), + removalMethod: removalMethod("removal_method").notNull(), + removalUrl: text("removal_url"), + requiresAccount: boolean("requires_account").default(false).notNull(), + requiresVerification: boolean("requires_verification").default(false).notNull(), + estimatedDays: integer("estimated_days").default(14).notNull(), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + categoryIdx: index("info_brokers_category_idx").on(table.category), + isActiveIdx: index("info_brokers_is_active_idx").on(table.isActive), + removalMethodIdx: index("info_brokers_removal_method_idx").on(table.removalMethod), +})); + +export const removalRequests = pgTable("removal_requests", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + brokerId: uuid("broker_id").notNull().references(() => infoBrokers.id), + status: removalStatus("status").default("PENDING").notNull(), + personalInfo: jsonb("personal_info").notNull(), + method: removalMethod("method").notNull(), + attempts: integer("attempts").default(0).notNull(), + nextRetryAt: timestamp("next_retry_at", { withTimezone: true, mode: "date" }), + submittedAt: timestamp("submitted_at", { withTimezone: true, mode: "date" }), + completedAt: timestamp("completed_at", { withTimezone: true, mode: "date" }), + error: text("error"), + notes: text("notes"), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subscriptionIdIdx: index("removal_requests_subscription_id_idx").on(table.subscriptionId), + brokerIdIdx: index("removal_requests_broker_id_idx").on(table.brokerId), + statusIdx: index("removal_requests_status_idx").on(table.status), + submittedAtIdx: index("removal_requests_submitted_at_idx").on(table.submittedAt), + subscriptionIdStatusIdx: index("removal_requests_sub_id_status_idx").on(table.subscriptionId, table.status), +})); + +export const brokerListings = pgTable("broker_listings", { + id: uuid("id").defaultRandom().primaryKey(), + subscriptionId: uuid("subscription_id").notNull().references(() => subscriptions.id, { onDelete: "cascade" }), + brokerId: uuid("broker_id").notNull(), + removalRequestId: uuid("removal_request_id").references(() => removalRequests.id), + url: text("url").notNull(), + dataFound: jsonb("data_found").notNull(), + screenshotUrl: text("screenshot_url"), + isRemoved: boolean("is_removed").default(false).notNull(), + removedAt: timestamp("removed_at", { withTimezone: true, mode: "date" }), + scannedAt: timestamp("scanned_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + subscriptionIdIdx: index("broker_listings_subscription_id_idx").on(table.subscriptionId), + brokerIdIdx: index("broker_listings_broker_id_idx").on(table.brokerId), + removalRequestIdIdx: index("broker_listings_removal_request_id_idx").on(table.removalRequestId), + isRemovedIdx: index("broker_listings_is_removed_idx").on(table.isRemoved), + subscriptionIdIsRemovedIdx: index("broker_listings_sub_id_is_removed_idx").on(table.subscriptionId, table.isRemoved), +})); diff --git a/web/src/server/db/schema/reports.ts b/web/src/server/db/schema/reports.ts new file mode 100644 index 0000000..e507a2e --- /dev/null +++ b/web/src/server/db/schema/reports.ts @@ -0,0 +1,30 @@ +import { pgTable, text, timestamp, index, uuid, jsonb } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { reportType, reportStatus } from "./enums"; + +export const securityReports = pgTable("security_reports", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + subscriptionId: uuid("subscription_id").notNull(), + reportType: reportType("report_type").notNull(), + status: reportStatus("status").default("PENDING").notNull(), + periodStart: timestamp("period_start", { withTimezone: true, mode: "date" }).notNull(), + periodEnd: timestamp("period_end", { withTimezone: true, mode: "date" }).notNull(), + title: text("title").notNull(), + summary: text("summary"), + htmlContent: text("html_content"), + pdfUrl: text("pdf_url"), + dataPayload: jsonb("data_payload"), + error: text("error"), + scheduledFor: timestamp("scheduled_for", { withTimezone: true, mode: "date" }), + deliveredAt: timestamp("delivered_at", { withTimezone: true, mode: "date" }), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("security_reports_user_id_idx").on(table.userId), + subscriptionIdIdx: index("security_reports_subscription_id_idx").on(table.subscriptionId), + reportTypeIdx: index("security_reports_report_type_idx").on(table.reportType), + statusIdx: index("security_reports_status_idx").on(table.status), + periodIdx: index("security_reports_period_idx").on(table.periodStart, table.periodEnd), + createdAtIdx: index("security_reports_created_at_idx").on(table.createdAt), +})); diff --git a/web/src/server/db/schema/spamshield.ts b/web/src/server/db/schema/spamshield.ts new file mode 100644 index 0000000..b916f9a --- /dev/null +++ b/web/src/server/db/schema/spamshield.ts @@ -0,0 +1,37 @@ +import { pgTable, text, timestamp, index, uuid, boolean, jsonb, doublePrecision, integer } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { feedbackType, ruleType, ruleAction } from "./enums"; + +export const spamFeedback = pgTable("spam_feedback", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + phoneNumber: text("phone_number").notNull(), + phoneNumberHash: text("phone_number_hash").notNull(), + isSpam: boolean("is_spam").notNull(), + confidence: doublePrecision("confidence"), + feedbackType: feedbackType("feedback_type").notNull(), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("spam_feedback_user_id_idx").on(table.userId), + phoneNumberHashIdx: index("spam_feedback_phone_number_hash_idx").on(table.phoneNumberHash), + isSpamIdx: index("spam_feedback_is_spam_idx").on(table.isSpam), +})); + +export const spamRules = pgTable("spam_rules", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }), + isGlobal: boolean("is_global").default(false).notNull(), + ruleType: ruleType("rule_type").notNull(), + pattern: text("pattern").notNull(), + action: ruleAction("action").notNull(), + priority: integer("priority").default(0).notNull(), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("spam_rules_user_id_idx").on(table.userId), + isGlobalIdx: index("spam_rules_is_global_idx").on(table.isGlobal), + ruleTypeIdx: index("spam_rules_rule_type_idx").on(table.ruleType), +})); diff --git a/web/src/server/db/schema/subscription.ts b/web/src/server/db/schema/subscription.ts new file mode 100644 index 0000000..f3efb49 --- /dev/null +++ b/web/src/server/db/schema/subscription.ts @@ -0,0 +1,47 @@ +import { pgTable, text, timestamp, uniqueIndex, index, uuid, boolean } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { familyMemberRole, subscriptionTier, subscriptionStatus } from "./enums"; + +export const familyGroups = pgTable("family_groups", { + id: uuid("id").defaultRandom().primaryKey(), + name: text("name").notNull(), + ownerId: uuid("owner_id").notNull().references(() => users.id), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + ownerIdIdx: index("family_groups_owner_id_idx").on(table.ownerId), + nameIdx: index("family_groups_name_idx").on(table.name), +})); + +export const familyGroupMembers = pgTable("family_group_members", { + id: uuid("id").defaultRandom().primaryKey(), + groupId: uuid("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + role: familyMemberRole("role").default("member").notNull(), + joinedAt: timestamp("joined_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + groupUserUnique: uniqueIndex("family_group_members_group_user_unique").on(table.groupId, table.userId), + groupIdIdx: index("family_group_members_group_id_idx").on(table.groupId), + userIdIdx: index("family_group_members_user_id_idx").on(table.userId), +})); + +export const subscriptions = pgTable("subscriptions", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + familyGroupId: uuid("family_group_id").references(() => familyGroups.id), + stripeId: text("stripe_id").unique(), + tier: subscriptionTier("tier").default("basic").notNull(), + status: subscriptionStatus("status").default("active").notNull(), + currentPeriodStart: timestamp("current_period_start", { withTimezone: true, mode: "date" }).notNull(), + currentPeriodEnd: timestamp("current_period_end", { withTimezone: true, mode: "date" }).notNull(), + cancelAtPeriodEnd: boolean("cancel_at_period_end").default(false).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("subscriptions_user_id_idx").on(table.userId), + familyGroupIdIdx: index("subscriptions_family_group_id_idx").on(table.familyGroupId), + stripeIdIdx: index("subscriptions_stripe_id_idx").on(table.stripeId), + tierIdx: index("subscriptions_tier_idx").on(table.tier), +})); diff --git a/web/src/server/db/schema/voiceprint.ts b/web/src/server/db/schema/voiceprint.ts new file mode 100644 index 0000000..5ba2bfc --- /dev/null +++ b/web/src/server/db/schema/voiceprint.ts @@ -0,0 +1,65 @@ +import { pgTable, text, timestamp, index, uuid, boolean, jsonb, doublePrecision, integer } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { detectionVerdict, analysisType, analysisJobStatus } from "./enums"; + +export const voiceEnrollments = pgTable("voice_enrollments", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + voiceHash: text("voice_hash").notNull(), + audioMetadata: jsonb("audio_metadata"), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}, (table) => ({ + userIdIdx: index("voice_enrollments_user_id_idx").on(table.userId), + voiceHashIdx: index("voice_enrollments_voice_hash_idx").on(table.voiceHash), +})); + +export const voiceAnalyses = pgTable("voice_analyses", { + id: uuid("id").defaultRandom().primaryKey(), + enrollmentId: uuid("enrollment_id").references(() => voiceEnrollments.id), + userId: uuid("user_id").notNull().references(() => users.id), + audioHash: text("audio_hash").notNull(), + isSynthetic: boolean("is_synthetic").notNull(), + confidence: doublePrecision("confidence").notNull(), + analysisResult: jsonb("analysis_result").notNull(), + audioUrl: text("audio_url").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdIdx: index("voice_analyses_user_id_idx").on(table.userId), + enrollmentIdIdx: index("voice_analyses_enrollment_id_idx").on(table.enrollmentId), + audioHashIdx: index("voice_analyses_audio_hash_idx").on(table.audioHash), +})); + +export const analysisJobs = pgTable("analysis_jobs", { + id: uuid("id").defaultRandom().primaryKey(), + userId: uuid("user_id").notNull().references(() => users.id), + analysisType: analysisType("analysis_type").notNull(), + audioFilePath: text("audio_file_path").notNull(), + status: analysisJobStatus("status").notNull(), + errorMessage: text("error_message"), + completedAt: timestamp("completed_at", { withTimezone: true, mode: "date" }), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdIdx: index("analysis_jobs_user_id_idx").on(table.userId), + statusIdx: index("analysis_jobs_status_idx").on(table.status), + createdAtIdx: index("analysis_jobs_created_at_idx").on(table.createdAt), +})); + +export const analysisResults = pgTable("analysis_results", { + id: uuid("id").defaultRandom().primaryKey(), + analysisJobId: uuid("analysis_job_id").notNull().unique().references(() => analysisJobs.id), + syntheticScore: doublePrecision("synthetic_score").notNull(), + verdict: detectionVerdict("verdict").notNull(), + confidence: doublePrecision("confidence").notNull(), + processingTimeMs: integer("processing_time_ms").notNull(), + matchedEnrollmentId: text("matched_enrollment_id"), + matchedSimilarity: doublePrecision("matched_similarity"), + modelVersion: text("model_version"), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + analysisJobIdIdx: index("analysis_results_analysis_job_id_idx").on(table.analysisJobId), + syntheticScoreIdx: index("analysis_results_synthetic_score_idx").on(table.syntheticScore), + verdictIdx: index("analysis_results_verdict_idx").on(table.verdict), +}));