diff --git a/.env.example b/.env.example index fe23b47..4b3d45c 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,3 @@ -# Copy this file to .env and add local environment values. +# Copy this file to .env and replace test-only values for non-test environments. +DATABASE_URL=postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public +REDIS_URL=redis://127.0.0.1:56379 diff --git a/apps/api/src/database/prisma.service.ts b/apps/api/src/database/prisma.service.ts new file mode 100644 index 0000000..749f483 --- /dev/null +++ b/apps/api/src/database/prisma.service.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from "@prisma/client"; + +/** Lightweight database lifecycle wrapper; Nest hooks can call these methods later. */ +export class PrismaService extends PrismaClient { + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/infra/compose.test.yaml b/infra/compose.test.yaml new file mode 100644 index 0000000..ea4e5a8 --- /dev/null +++ b/infra/compose.test.yaml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_TEST_DB:-drift_bottle_test} + POSTGRES_USER: ${POSTGRES_TEST_USER:-drift} + POSTGRES_PASSWORD: ${POSTGRES_TEST_PASSWORD:-drift_test_only} + ports: + - "${POSTGRES_TEST_PORT:-55432}:5432" + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_TEST_USER:-drift} -d ${POSTGRES_TEST_DB:-drift_bottle_test}", + ] + interval: 2s + timeout: 3s + retries: 20 + tmpfs: ["/var/lib/postgresql/data"] + redis: + image: redis:7-alpine + command: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - "${REDIS_TEST_PORT:-56379}:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 20 + tmpfs: ["/data"] diff --git a/package.json b/package.json index 0d9c05b..f44e8a9 100644 --- a/package.json +++ b/package.json @@ -5,17 +5,36 @@ "packageManager": "pnpm@9.15.0", "scripts": { "test": "vitest run", + "test:database": "vitest run tests/integration/database.spec.ts", "typecheck": "tsc --noEmit -p tsconfig.base.json", "lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .", - "build": "corepack pnpm --recursive run build" + "build": "corepack pnpm prisma generate && corepack pnpm --recursive run build", + "prisma:generate": "prisma generate", + "prisma:migrate:deploy": "prisma migrate deploy", + "prisma:seed": "tsx prisma/seed.ts" + }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, + "pnpm": { + "overrides": { + "deepmerge-ts@<8.0.0": "8.0.0", + "effect@<3.20.0": "3.20.0" + } }, "devDependencies": { + "@types/node": "22.19.3", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.0", "eslint": "^8.57.1", "prettier": "^3.4.2", + "prisma": "6.19.0", + "tsx": "4.20.6", "typescript": "^5.6.3", "vite": "^7.3.2", "vitest": "^4.1.11" + }, + "dependencies": { + "@prisma/client": "6.19.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dfb140..04d76c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,20 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + deepmerge-ts@<8.0.0: 8.0.0 + effect@<3.20.0: 3.20.0 + importers: .: + dependencies: + "@prisma/client": + specifier: 6.19.0 + version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3) devDependencies: + "@types/node": + specifier: 22.19.3 + version: 22.19.3 "@typescript-eslint/eslint-plugin": specifier: ^8.18.0 version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) @@ -19,19 +30,34 @@ importers: prettier: specifier: ^3.4.2 version: 3.9.6 + prisma: + specifier: 6.19.0 + version: 6.19.0(typescript@5.9.3) + tsx: + specifier: 4.20.6 + version: 4.20.6 typescript: specifier: ^5.6.3 version: 5.9.3 vite: specifier: ^7.3.2 - version: 7.3.6 + version: 7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6) vitest: specifier: ^4.1.11 - version: 4.1.11(vite@7.3.6) + version: 4.1.11(@types/node@22.19.3)(vite@7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6)) packages/contracts: {} packages: + "@esbuild/aix-ppc64@0.25.12": + resolution: + { + integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [aix] + "@esbuild/aix-ppc64@0.28.2": resolution: { @@ -41,6 +67,15 @@ packages: cpu: [ppc64] os: [aix] + "@esbuild/android-arm64@0.25.12": + resolution: + { + integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [android] + "@esbuild/android-arm64@0.28.2": resolution: { @@ -50,6 +85,15 @@ packages: cpu: [arm64] os: [android] + "@esbuild/android-arm@0.25.12": + resolution: + { + integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [android] + "@esbuild/android-arm@0.28.2": resolution: { @@ -59,6 +103,15 @@ packages: cpu: [arm] os: [android] + "@esbuild/android-x64@0.25.12": + resolution: + { + integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [android] + "@esbuild/android-x64@0.28.2": resolution: { @@ -68,6 +121,15 @@ packages: cpu: [x64] os: [android] + "@esbuild/darwin-arm64@0.25.12": + resolution: + { + integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [darwin] + "@esbuild/darwin-arm64@0.28.2": resolution: { @@ -77,6 +139,15 @@ packages: cpu: [arm64] os: [darwin] + "@esbuild/darwin-x64@0.25.12": + resolution: + { + integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [darwin] + "@esbuild/darwin-x64@0.28.2": resolution: { @@ -86,6 +157,15 @@ packages: cpu: [x64] os: [darwin] + "@esbuild/freebsd-arm64@0.25.12": + resolution: + { + integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [freebsd] + "@esbuild/freebsd-arm64@0.28.2": resolution: { @@ -95,6 +175,15 @@ packages: cpu: [arm64] os: [freebsd] + "@esbuild/freebsd-x64@0.25.12": + resolution: + { + integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [freebsd] + "@esbuild/freebsd-x64@0.28.2": resolution: { @@ -104,6 +193,15 @@ packages: cpu: [x64] os: [freebsd] + "@esbuild/linux-arm64@0.25.12": + resolution: + { + integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [linux] + "@esbuild/linux-arm64@0.28.2": resolution: { @@ -113,6 +211,15 @@ packages: cpu: [arm64] os: [linux] + "@esbuild/linux-arm@0.25.12": + resolution: + { + integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [linux] + "@esbuild/linux-arm@0.28.2": resolution: { @@ -122,6 +229,15 @@ packages: cpu: [arm] os: [linux] + "@esbuild/linux-ia32@0.25.12": + resolution: + { + integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [linux] + "@esbuild/linux-ia32@0.28.2": resolution: { @@ -131,6 +247,15 @@ packages: cpu: [ia32] os: [linux] + "@esbuild/linux-loong64@0.25.12": + resolution: + { + integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==, + } + engines: { node: ">=18" } + cpu: [loong64] + os: [linux] + "@esbuild/linux-loong64@0.28.2": resolution: { @@ -140,6 +265,15 @@ packages: cpu: [loong64] os: [linux] + "@esbuild/linux-mips64el@0.25.12": + resolution: + { + integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==, + } + engines: { node: ">=18" } + cpu: [mips64el] + os: [linux] + "@esbuild/linux-mips64el@0.28.2": resolution: { @@ -149,6 +283,15 @@ packages: cpu: [mips64el] os: [linux] + "@esbuild/linux-ppc64@0.25.12": + resolution: + { + integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [linux] + "@esbuild/linux-ppc64@0.28.2": resolution: { @@ -158,6 +301,15 @@ packages: cpu: [ppc64] os: [linux] + "@esbuild/linux-riscv64@0.25.12": + resolution: + { + integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==, + } + engines: { node: ">=18" } + cpu: [riscv64] + os: [linux] + "@esbuild/linux-riscv64@0.28.2": resolution: { @@ -167,6 +319,15 @@ packages: cpu: [riscv64] os: [linux] + "@esbuild/linux-s390x@0.25.12": + resolution: + { + integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==, + } + engines: { node: ">=18" } + cpu: [s390x] + os: [linux] + "@esbuild/linux-s390x@0.28.2": resolution: { @@ -176,6 +337,15 @@ packages: cpu: [s390x] os: [linux] + "@esbuild/linux-x64@0.25.12": + resolution: + { + integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [linux] + "@esbuild/linux-x64@0.28.2": resolution: { @@ -185,6 +355,15 @@ packages: cpu: [x64] os: [linux] + "@esbuild/netbsd-arm64@0.25.12": + resolution: + { + integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [netbsd] + "@esbuild/netbsd-arm64@0.28.2": resolution: { @@ -194,6 +373,15 @@ packages: cpu: [arm64] os: [netbsd] + "@esbuild/netbsd-x64@0.25.12": + resolution: + { + integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [netbsd] + "@esbuild/netbsd-x64@0.28.2": resolution: { @@ -203,6 +391,15 @@ packages: cpu: [x64] os: [netbsd] + "@esbuild/openbsd-arm64@0.25.12": + resolution: + { + integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openbsd] + "@esbuild/openbsd-arm64@0.28.2": resolution: { @@ -212,6 +409,15 @@ packages: cpu: [arm64] os: [openbsd] + "@esbuild/openbsd-x64@0.25.12": + resolution: + { + integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [openbsd] + "@esbuild/openbsd-x64@0.28.2": resolution: { @@ -221,6 +427,15 @@ packages: cpu: [x64] os: [openbsd] + "@esbuild/openharmony-arm64@0.25.12": + resolution: + { + integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + "@esbuild/openharmony-arm64@0.28.2": resolution: { @@ -230,6 +445,15 @@ packages: cpu: [arm64] os: [openharmony] + "@esbuild/sunos-x64@0.25.12": + resolution: + { + integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [sunos] + "@esbuild/sunos-x64@0.28.2": resolution: { @@ -239,6 +463,15 @@ packages: cpu: [x64] os: [sunos] + "@esbuild/win32-arm64@0.25.12": + resolution: + { + integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [win32] + "@esbuild/win32-arm64@0.28.2": resolution: { @@ -248,6 +481,15 @@ packages: cpu: [arm64] os: [win32] + "@esbuild/win32-ia32@0.25.12": + resolution: + { + integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [win32] + "@esbuild/win32-ia32@0.28.2": resolution: { @@ -257,6 +499,15 @@ packages: cpu: [ia32] os: [win32] + "@esbuild/win32-x64@0.25.12": + resolution: + { + integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [win32] + "@esbuild/win32-x64@0.28.2": resolution: { @@ -354,6 +605,57 @@ packages: } engines: { node: ">= 8" } + "@prisma/client@6.19.0": + resolution: + { + integrity: sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==, + } + engines: { node: ">=18.18" } + peerDependencies: + prisma: "*" + typescript: ">=5.1.0" + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + + "@prisma/config@6.19.0": + resolution: + { + integrity: sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==, + } + + "@prisma/debug@6.19.0": + resolution: + { + integrity: sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==, + } + + "@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773": + resolution: + { + integrity: sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==, + } + + "@prisma/engines@6.19.0": + resolution: + { + integrity: sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==, + } + + "@prisma/fetch-engine@6.19.0": + resolution: + { + integrity: sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==, + } + + "@prisma/get-platform@6.19.0": + resolution: + { + integrity: sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==, + } + "@rollup/rollup-android-arm-eabi@4.63.2": resolution: { @@ -578,6 +880,12 @@ packages: integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, } + "@types/node@22.19.3": + resolution: + { + integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==, + } + "@typescript-eslint/eslint-plugin@8.70.0": resolution: { @@ -798,6 +1106,17 @@ packages: } engines: { node: 20 || >=22 } + c12@3.1.0: + resolution: + { + integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==, + } + peerDependencies: + magicast: ^0.3.5 + peerDependenciesMeta: + magicast: + optional: true + callsites@3.1.0: resolution: { @@ -819,6 +1138,25 @@ packages: } engines: { node: ">=10" } + chokidar@4.0.3: + resolution: + { + integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==, + } + engines: { node: ">= 14.16.0" } + + citty@0.1.6: + resolution: + { + integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==, + } + + citty@0.2.2: + resolution: + { + integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==, + } + color-convert@2.0.1: resolution: { @@ -838,6 +1176,25 @@ packages: integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, } + confbox@0.2.4: + resolution: + { + integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==, + } + + confbox@0.3.1: + resolution: + { + integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==, + } + + consola@3.4.2: + resolution: + { + integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, + } + engines: { node: ^14.18.0 || >=16.10.0 } + convert-source-map@2.0.0: resolution: { @@ -869,6 +1226,25 @@ packages: integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + deepmerge-ts@8.0.0: + resolution: + { + integrity: sha512-ICNjaP0ML+eSdEpJYQC46XiAn/UjAdwbEl0dE8p85ZTeNDinN4Kd4+9jS4OSAuH7st6eC7rQhsqTF5zIDaUm2g==, + } + engines: { node: ">=16.0.0" } + + defu@6.1.7: + resolution: + { + integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, + } + + destr@2.0.5: + resolution: + { + integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, + } + doctrine@3.0.0: resolution: { @@ -876,12 +1252,40 @@ packages: } engines: { node: ">=6.0.0" } + dotenv@16.6.1: + resolution: + { + integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, + } + engines: { node: ">=12" } + + effect@3.20.0: + resolution: + { + integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==, + } + + empathic@2.0.0: + resolution: + { + integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==, + } + engines: { node: ">=14" } + es-module-lexer@2.3.2: resolution: { integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==, } + esbuild@0.25.12: + resolution: + { + integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==, + } + engines: { node: ">=18" } + hasBin: true + esbuild@0.28.2: resolution: { @@ -975,6 +1379,19 @@ packages: } engines: { node: ">=12.0.0" } + exsolve@1.1.1: + resolution: + { + integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==, + } + + fast-check@3.23.2: + resolution: + { + integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==, + } + engines: { node: ">=8.0.0" } + fast-deep-equal@3.1.3: resolution: { @@ -1052,6 +1469,19 @@ packages: engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } os: [darwin] + get-tsconfig@4.14.3: + resolution: + { + integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==, + } + + giget@2.0.0: + resolution: + { + integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==, + } + hasBin: true + glob-parent@6.0.2: resolution: { @@ -1154,6 +1584,13 @@ packages: integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } + jiti@2.7.0: + resolution: + { + integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==, + } + hasBin: true + js-yaml@4.3.2: resolution: { @@ -1244,6 +1681,20 @@ packages: integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, } + node-fetch-native@1.6.7: + resolution: + { + integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==, + } + + nypm@0.6.10: + resolution: + { + integrity: sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw==, + } + engines: { node: ">=18" } + hasBin: true + obug@2.2.1: resolution: { @@ -1251,6 +1702,12 @@ packages: } engines: { node: ">=12.20.0" } + ohash@2.0.12: + resolution: + { + integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==, + } + once@1.4.0: resolution: { @@ -1312,6 +1769,12 @@ packages: integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, } + perfect-debounce@1.0.0: + resolution: + { + integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, + } + picocolors@1.1.1: resolution: { @@ -1325,6 +1788,12 @@ packages: } engines: { node: ">=12" } + pkg-types@2.3.3: + resolution: + { + integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==, + } + postcss@8.5.28: resolution: { @@ -1347,6 +1816,19 @@ packages: engines: { node: ">=14" } hasBin: true + prisma@6.19.0: + resolution: + { + integrity: sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==, + } + engines: { node: ">=18.18" } + hasBin: true + peerDependencies: + typescript: ">=5.1.0" + peerDependenciesMeta: + typescript: + optional: true + punycode@2.3.1: resolution: { @@ -1354,12 +1836,31 @@ packages: } engines: { node: ">=6" } + pure-rand@6.1.0: + resolution: + { + integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==, + } + queue-microtask@1.2.3: resolution: { integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, } + rc9@2.1.2: + resolution: + { + integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==, + } + + readdirp@4.1.2: + resolution: + { + integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==, + } + engines: { node: ">= 14.18.0" } + resolve-from@4.0.0: resolution: { @@ -1367,6 +1868,12 @@ packages: } engines: { node: ">=4" } + resolve-pkg-maps@1.0.0: + resolution: + { + integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==, + } + reusify@1.1.0: resolution: { @@ -1506,6 +2013,14 @@ packages: peerDependencies: typescript: ">=4.8.4" + tsx@4.20.6: + resolution: + { + integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==, + } + engines: { node: ">=18.0.0" } + hasBin: true + type-check@0.4.0: resolution: { @@ -1528,6 +2043,12 @@ packages: engines: { node: ">=14.17" } hasBin: true + undici-types@6.21.0: + resolution: + { + integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, + } + uri-js@4.4.1: resolution: { @@ -1658,81 +2179,159 @@ packages: engines: { node: ">=10" } snapshots: + "@esbuild/aix-ppc64@0.25.12": + optional: true + "@esbuild/aix-ppc64@0.28.2": optional: true + "@esbuild/android-arm64@0.25.12": + optional: true + "@esbuild/android-arm64@0.28.2": optional: true + "@esbuild/android-arm@0.25.12": + optional: true + "@esbuild/android-arm@0.28.2": optional: true + "@esbuild/android-x64@0.25.12": + optional: true + "@esbuild/android-x64@0.28.2": optional: true + "@esbuild/darwin-arm64@0.25.12": + optional: true + "@esbuild/darwin-arm64@0.28.2": optional: true + "@esbuild/darwin-x64@0.25.12": + optional: true + "@esbuild/darwin-x64@0.28.2": optional: true + "@esbuild/freebsd-arm64@0.25.12": + optional: true + "@esbuild/freebsd-arm64@0.28.2": optional: true + "@esbuild/freebsd-x64@0.25.12": + optional: true + "@esbuild/freebsd-x64@0.28.2": optional: true + "@esbuild/linux-arm64@0.25.12": + optional: true + "@esbuild/linux-arm64@0.28.2": optional: true + "@esbuild/linux-arm@0.25.12": + optional: true + "@esbuild/linux-arm@0.28.2": optional: true + "@esbuild/linux-ia32@0.25.12": + optional: true + "@esbuild/linux-ia32@0.28.2": optional: true + "@esbuild/linux-loong64@0.25.12": + optional: true + "@esbuild/linux-loong64@0.28.2": optional: true + "@esbuild/linux-mips64el@0.25.12": + optional: true + "@esbuild/linux-mips64el@0.28.2": optional: true + "@esbuild/linux-ppc64@0.25.12": + optional: true + "@esbuild/linux-ppc64@0.28.2": optional: true + "@esbuild/linux-riscv64@0.25.12": + optional: true + "@esbuild/linux-riscv64@0.28.2": optional: true + "@esbuild/linux-s390x@0.25.12": + optional: true + "@esbuild/linux-s390x@0.28.2": optional: true + "@esbuild/linux-x64@0.25.12": + optional: true + "@esbuild/linux-x64@0.28.2": optional: true + "@esbuild/netbsd-arm64@0.25.12": + optional: true + "@esbuild/netbsd-arm64@0.28.2": optional: true + "@esbuild/netbsd-x64@0.25.12": + optional: true + "@esbuild/netbsd-x64@0.28.2": optional: true + "@esbuild/openbsd-arm64@0.25.12": + optional: true + "@esbuild/openbsd-arm64@0.28.2": optional: true + "@esbuild/openbsd-x64@0.25.12": + optional: true + "@esbuild/openbsd-x64@0.28.2": optional: true + "@esbuild/openharmony-arm64@0.25.12": + optional: true + "@esbuild/openharmony-arm64@0.28.2": optional: true + "@esbuild/sunos-x64@0.25.12": + optional: true + "@esbuild/sunos-x64@0.28.2": optional: true + "@esbuild/win32-arm64@0.25.12": + optional: true + "@esbuild/win32-arm64@0.28.2": optional: true + "@esbuild/win32-ia32@0.25.12": + optional: true + "@esbuild/win32-ia32@0.28.2": optional: true + "@esbuild/win32-x64@0.25.12": + optional: true + "@esbuild/win32-x64@0.28.2": optional: true @@ -1788,6 +2387,42 @@ snapshots: "@nodelib/fs.scandir": 2.1.5 fastq: 1.20.3 + "@prisma/client@6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)": + optionalDependencies: + prisma: 6.19.0(typescript@5.9.3) + typescript: 5.9.3 + + "@prisma/config@6.19.0": + dependencies: + c12: 3.1.0 + deepmerge-ts: 8.0.0 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + + "@prisma/debug@6.19.0": {} + + "@prisma/engines-version@6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773": + {} + + "@prisma/engines@6.19.0": + dependencies: + "@prisma/debug": 6.19.0 + "@prisma/engines-version": 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 + "@prisma/fetch-engine": 6.19.0 + "@prisma/get-platform": 6.19.0 + + "@prisma/fetch-engine@6.19.0": + dependencies: + "@prisma/debug": 6.19.0 + "@prisma/engines-version": 6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773 + "@prisma/get-platform": 6.19.0 + + "@prisma/get-platform@6.19.0": + dependencies: + "@prisma/debug": 6.19.0 + "@rollup/rollup-android-arm-eabi@4.63.2": optional: true @@ -1874,6 +2509,10 @@ snapshots: "@types/estree@1.0.9": {} + "@types/node@22.19.3": + dependencies: + undici-types: 6.21.0 + "@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)": dependencies: "@eslint-community/regexpp": 4.12.2 @@ -1976,13 +2615,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - "@vitest/mocker@4.1.11(vite@7.3.6)": + "@vitest/mocker@4.1.11(vite@7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6))": dependencies: "@vitest/spy": 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.6 + vite: 7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6) "@vitest/pretty-format@4.1.11": dependencies: @@ -2044,6 +2683,21 @@ snapshots: dependencies: balanced-match: 4.0.4 + c12@3.1.0: + dependencies: + chokidar: 4.0.3 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 16.6.1 + exsolve: 1.1.1 + giget: 2.0.0 + jiti: 2.7.0 + ohash: 2.0.12 + pathe: 2.0.3 + perfect-debounce: 1.0.0 + pkg-types: 2.3.3 + rc9: 2.1.2 + callsites@3.1.0: {} chai@6.2.2: {} @@ -2053,6 +2707,16 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2061,6 +2725,12 @@ snapshots: concat-map@0.0.1: {} + confbox@0.2.4: {} + + confbox@0.3.1: {} + + consola@3.4.2: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -2075,12 +2745,56 @@ snapshots: deep-is@0.1.4: {} + deepmerge-ts@8.0.0: {} + + defu@6.1.7: {} + + destr@2.0.5: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 + dotenv@16.6.1: {} + + effect@3.20.0: + dependencies: + "@standard-schema/spec": 1.1.0 + fast-check: 3.23.2 + + empathic@2.0.0: {} + es-module-lexer@2.3.2: {} + esbuild@0.25.12: + optionalDependencies: + "@esbuild/aix-ppc64": 0.25.12 + "@esbuild/android-arm": 0.25.12 + "@esbuild/android-arm64": 0.25.12 + "@esbuild/android-x64": 0.25.12 + "@esbuild/darwin-arm64": 0.25.12 + "@esbuild/darwin-x64": 0.25.12 + "@esbuild/freebsd-arm64": 0.25.12 + "@esbuild/freebsd-x64": 0.25.12 + "@esbuild/linux-arm": 0.25.12 + "@esbuild/linux-arm64": 0.25.12 + "@esbuild/linux-ia32": 0.25.12 + "@esbuild/linux-loong64": 0.25.12 + "@esbuild/linux-mips64el": 0.25.12 + "@esbuild/linux-ppc64": 0.25.12 + "@esbuild/linux-riscv64": 0.25.12 + "@esbuild/linux-s390x": 0.25.12 + "@esbuild/linux-x64": 0.25.12 + "@esbuild/netbsd-arm64": 0.25.12 + "@esbuild/netbsd-x64": 0.25.12 + "@esbuild/openbsd-arm64": 0.25.12 + "@esbuild/openbsd-x64": 0.25.12 + "@esbuild/openharmony-arm64": 0.25.12 + "@esbuild/sunos-x64": 0.25.12 + "@esbuild/win32-arm64": 0.25.12 + "@esbuild/win32-ia32": 0.25.12 + "@esbuild/win32-x64": 0.25.12 + esbuild@0.28.2: optionalDependencies: "@esbuild/aix-ppc64": 0.28.2 @@ -2188,6 +2902,12 @@ snapshots: expect-type@1.4.0: {} + exsolve@1.1.1: {} + + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2224,6 +2944,19 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.7 + node-fetch-native: 1.6.7 + nypm: 0.6.10 + pathe: 2.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2273,6 +3006,8 @@ snapshots: isexe@2.0.0: {} + jiti@2.7.0: {} + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -2316,8 +3051,18 @@ snapshots: natural-compare@1.4.0: {} + node-fetch-native@1.6.7: {} + + nypm@0.6.10: + dependencies: + citty: 0.2.2 + pathe: 2.0.3 + tinyexec: 1.3.1 + obug@2.2.1: {} + ohash@2.0.12: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -2351,10 +3096,18 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.1 + pathe: 2.0.3 + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -2365,12 +3118,32 @@ snapshots: prettier@3.9.6: {} + prisma@6.19.0(typescript@5.9.3): + dependencies: + "@prisma/config": 6.19.0 + "@prisma/engines": 6.19.0 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - magicast + punycode@2.3.1: {} + pure-rand@6.1.0: {} + queue-microtask@1.2.3: {} + rc9@2.1.2: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + + readdirp@4.1.2: {} + resolve-from@4.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rimraf@3.0.2: @@ -2456,6 +3229,13 @@ snapshots: dependencies: typescript: 5.9.3 + tsx@4.20.6: + dependencies: + esbuild: 0.25.12 + get-tsconfig: 4.14.3 + optionalDependencies: + fsevents: 2.3.3 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2464,11 +3244,13 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - vite@7.3.6: + vite@7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6): dependencies: esbuild: 0.28.2 fdir: 6.5.0(picomatch@4.0.7) @@ -2477,12 +3259,15 @@ snapshots: rollup: 4.63.2 tinyglobby: 0.2.17 optionalDependencies: + "@types/node": 22.19.3 fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.20.6 - vitest@4.1.11(vite@7.3.6): + vitest@4.1.11(@types/node@22.19.3)(vite@7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6)): dependencies: "@vitest/expect": 4.1.11 - "@vitest/mocker": 4.1.11(vite@7.3.6) + "@vitest/mocker": 4.1.11(vite@7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6)) "@vitest/pretty-format": 4.1.11 "@vitest/runner": 4.1.11 "@vitest/snapshot": 4.1.11 @@ -2499,8 +3284,10 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.6 + vite: 7.3.6(@types/node@22.19.3)(jiti@2.7.0)(tsx@4.20.6) why-is-node-running: 2.3.0 + optionalDependencies: + "@types/node": 22.19.3 transitivePeerDependencies: - msw diff --git a/prisma/migrations/0001_init/migration.sql b/prisma/migrations/0001_init/migration.sql new file mode 100644 index 0000000..591f767 --- /dev/null +++ b/prisma/migrations/0001_init/migration.sql @@ -0,0 +1,463 @@ +-- CreateEnum +CREATE TYPE "AccountStatus" AS ENUM ('ACTIVE', 'SUSPENDED', 'DELETED'); + +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('PHONE', 'APPLE', 'WECHAT'); + +-- CreateEnum +CREATE TYPE "BottleStatus" AS ENUM ('DRAFT', 'IN_POOL', 'PICKED', 'EXPIRED', 'REMOVED'); + +-- CreateEnum +CREATE TYPE "ConversationStatus" AS ENUM ('ACTIVE', 'CLOSED'); + +-- CreateEnum +CREATE TYPE "MessageStatus" AS ENUM ('SENT', 'DELETED', 'MODERATED'); + +-- CreateEnum +CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'REVIEWING', 'RESOLVED', 'DISMISSED'); + +-- CreateEnum +CREATE TYPE "ModerationTaskStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "SanctionType" AS ENUM ('WARNING', 'MUTE', 'SUSPENSION', 'BAN'); + +-- CreateEnum +CREATE TYPE "NotificationStatus" AS ENUM ('PENDING', 'SENT', 'READ', 'FAILED'); + +-- CreateEnum +CREATE TYPE "OutboxStatus" AS ENUM ('PENDING', 'PROCESSING', 'PUBLISHED', 'FAILED'); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" UUID NOT NULL, + "phone_ciphertext" BYTEA NOT NULL, + "phone_hmac" VARCHAR(128) NOT NULL, + "status" "AccountStatus" NOT NULL DEFAULT 'ACTIVE', + "token_version" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "anonymous_profiles" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "nickname" VARCHAR(64) NOT NULL, + "avatar_key" VARCHAR(255), + "bio" VARCHAR(500), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "anonymous_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "auth_identities" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "provider" "AuthProvider" NOT NULL, + "provider_subject" VARCHAR(255) NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "auth_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "refresh_token_hash" VARCHAR(255) NOT NULL, + "device_id" VARCHAR(255), + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bottles" ( + "id" UUID NOT NULL, + "author_id" UUID NOT NULL, + "content_text" TEXT NOT NULL, + "status" "BottleStatus" NOT NULL DEFAULT 'DRAFT', + "picked_at" TIMESTAMPTZ(3), + "expires_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "bottles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bottle_pick_leases" ( + "id" UUID NOT NULL, + "bottle_id" UUID NOT NULL, + "picker_id" UUID NOT NULL, + "lease_token_hash" VARCHAR(255) NOT NULL, + "expires_at" TIMESTAMPTZ(3) NOT NULL, + "consumed_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bottle_pick_leases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "bottle_pick_history" ( + "id" UUID NOT NULL, + "bottle_id" UUID NOT NULL, + "picker_id" UUID NOT NULL, + "picked_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "bottle_pick_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "daily_usage" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "usage_date" DATE NOT NULL, + "bottles_created" INTEGER NOT NULL DEFAULT 0, + "bottles_picked" INTEGER NOT NULL DEFAULT 0, + "messages_sent" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "daily_usage_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "conversations" ( + "id" UUID NOT NULL, + "source_bottle_id" UUID NOT NULL, + "status" "ConversationStatus" NOT NULL DEFAULT 'ACTIVE', + "next_seq" INTEGER NOT NULL DEFAULT 1, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "conversations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "conversation_members" ( + "id" UUID NOT NULL, + "conversation_id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "joined_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "left_at" TIMESTAMPTZ(3), + "last_read_seq" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "conversation_members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" UUID NOT NULL, + "conversation_id" UUID NOT NULL, + "sender_id" UUID NOT NULL, + "client_msg_id" VARCHAR(128) NOT NULL, + "seq" INTEGER NOT NULL, + "content_text" TEXT NOT NULL, + "status" "MessageStatus" NOT NULL DEFAULT 'SENT', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "blocks" ( + "id" UUID NOT NULL, + "blocker_id" UUID NOT NULL, + "blocked_id" UUID NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "blocks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reports" ( + "id" UUID NOT NULL, + "reporter_id" UUID NOT NULL, + "reported_account_id" UUID, + "bottle_id" UUID, + "conversation_id" UUID, + "message_id" UUID, + "reason" VARCHAR(100) NOT NULL, + "details" TEXT, + "status" "ReportStatus" NOT NULL DEFAULT 'PENDING', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "reports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "moderation_tasks" ( + "id" UUID NOT NULL, + "report_id" UUID NOT NULL, + "assigned_to_id" UUID, + "status" "ModerationTaskStatus" NOT NULL DEFAULT 'PENDING', + "decision" TEXT, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "moderation_tasks_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sanctions" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "issued_by_id" UUID, + "moderation_task_id" UUID, + "type" "SanctionType" NOT NULL, + "reason" TEXT NOT NULL, + "starts_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3), + "revoked_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "sanctions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notifications" ( + "id" UUID NOT NULL, + "account_id" UUID NOT NULL, + "type" VARCHAR(100) NOT NULL, + "payload" JSONB NOT NULL, + "status" "NotificationStatus" NOT NULL DEFAULT 'PENDING', + "sent_at" TIMESTAMPTZ(3), + "read_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "outbox_events" ( + "id" UUID NOT NULL, + "aggregate_type" VARCHAR(100) NOT NULL, + "aggregate_id" UUID NOT NULL, + "event_type" VARCHAR(150) NOT NULL, + "payload" JSONB NOT NULL, + "status" "OutboxStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "available_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "outbox_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "audit_logs" ( + "id" UUID NOT NULL, + "actor_id" UUID, + "action" VARCHAR(150) NOT NULL, + "entity_type" VARCHAR(100) NOT NULL, + "entity_id" UUID, + "request_id" VARCHAR(128), + "ip_hash" VARCHAR(128), + "metadata" JSONB, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_phone_hmac_key" ON "accounts"("phone_hmac"); + +-- CreateIndex +CREATE UNIQUE INDEX "anonymous_profiles_account_id_key" ON "anonymous_profiles"("account_id"); + +-- CreateIndex +CREATE INDEX "auth_identities_account_id_idx" ON "auth_identities"("account_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "auth_identities_provider_provider_subject_key" ON "auth_identities"("provider", "provider_subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "sessions_refresh_token_hash_key" ON "sessions"("refresh_token_hash"); + +-- CreateIndex +CREATE INDEX "sessions_account_id_expires_at_idx" ON "sessions"("account_id", "expires_at"); + +-- CreateIndex +CREATE INDEX "bottles_status_created_at_idx" ON "bottles"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "bottles_author_id_created_at_idx" ON "bottles"("author_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "bottle_pick_leases_lease_token_hash_key" ON "bottle_pick_leases"("lease_token_hash"); + +-- CreateIndex +CREATE INDEX "bottle_pick_leases_picker_id_expires_at_idx" ON "bottle_pick_leases"("picker_id", "expires_at"); + +-- CreateIndex +CREATE INDEX "bottle_pick_leases_bottle_id_expires_at_idx" ON "bottle_pick_leases"("bottle_id", "expires_at"); + +-- CreateIndex +CREATE INDEX "bottle_pick_history_picker_id_picked_at_idx" ON "bottle_pick_history"("picker_id", "picked_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "bottle_pick_history_bottle_id_picker_id_key" ON "bottle_pick_history"("bottle_id", "picker_id"); + +-- CreateIndex +CREATE INDEX "daily_usage_usage_date_idx" ON "daily_usage"("usage_date"); + +-- CreateIndex +CREATE UNIQUE INDEX "daily_usage_account_id_usage_date_key" ON "daily_usage"("account_id", "usage_date"); + +-- CreateIndex +CREATE UNIQUE INDEX "conversations_source_bottle_id_key" ON "conversations"("source_bottle_id"); + +-- CreateIndex +CREATE INDEX "conversations_status_updated_at_idx" ON "conversations"("status", "updated_at"); + +-- CreateIndex +CREATE INDEX "conversation_members_account_id_joined_at_idx" ON "conversation_members"("account_id", "joined_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "conversation_members_conversation_id_account_id_key" ON "conversation_members"("conversation_id", "account_id"); + +-- CreateIndex +CREATE INDEX "messages_sender_id_created_at_idx" ON "messages"("sender_id", "created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "messages_conversation_id_client_msg_id_key" ON "messages"("conversation_id", "client_msg_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "messages_conversation_id_seq_key" ON "messages"("conversation_id", "seq"); + +-- CreateIndex +CREATE INDEX "blocks_blocked_id_idx" ON "blocks"("blocked_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "blocks_blocker_id_blocked_id_key" ON "blocks"("blocker_id", "blocked_id"); + +-- CreateIndex +CREATE INDEX "reports_status_created_at_idx" ON "reports"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "reports_reported_account_id_created_at_idx" ON "reports"("reported_account_id", "created_at"); + +-- CreateIndex +CREATE INDEX "moderation_tasks_status_created_at_idx" ON "moderation_tasks"("status", "created_at"); + +-- CreateIndex +CREATE INDEX "moderation_tasks_assigned_to_id_status_idx" ON "moderation_tasks"("assigned_to_id", "status"); + +-- CreateIndex +CREATE INDEX "sanctions_account_id_expires_at_idx" ON "sanctions"("account_id", "expires_at"); + +-- CreateIndex +CREATE INDEX "notifications_account_id_status_created_at_idx" ON "notifications"("account_id", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "outbox_events_status_available_at_idx" ON "outbox_events"("status", "available_at"); + +-- CreateIndex +CREATE INDEX "outbox_events_aggregate_type_aggregate_id_idx" ON "outbox_events"("aggregate_type", "aggregate_id"); + +-- CreateIndex +CREATE INDEX "audit_logs_actor_id_created_at_idx" ON "audit_logs"("actor_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_entity_type_entity_id_created_at_idx" ON "audit_logs"("entity_type", "entity_id", "created_at"); + +-- CreateIndex +CREATE INDEX "audit_logs_request_id_idx" ON "audit_logs"("request_id"); + +-- AddForeignKey +ALTER TABLE "anonymous_profiles" ADD CONSTRAINT "anonymous_profiles_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "auth_identities" ADD CONSTRAINT "auth_identities_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bottles" ADD CONSTRAINT "bottles_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bottle_pick_leases" ADD CONSTRAINT "bottle_pick_leases_bottle_id_fkey" FOREIGN KEY ("bottle_id") REFERENCES "bottles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bottle_pick_leases" ADD CONSTRAINT "bottle_pick_leases_picker_id_fkey" FOREIGN KEY ("picker_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bottle_pick_history" ADD CONSTRAINT "bottle_pick_history_bottle_id_fkey" FOREIGN KEY ("bottle_id") REFERENCES "bottles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "bottle_pick_history" ADD CONSTRAINT "bottle_pick_history_picker_id_fkey" FOREIGN KEY ("picker_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "daily_usage" ADD CONSTRAINT "daily_usage_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversations" ADD CONSTRAINT "conversations_source_bottle_id_fkey" FOREIGN KEY ("source_bottle_id") REFERENCES "bottles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "conversation_members" ADD CONSTRAINT "conversation_members_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocker_id_fkey" FOREIGN KEY ("blocker_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "blocks" ADD CONSTRAINT "blocks_blocked_id_fkey" FOREIGN KEY ("blocked_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_reported_account_id_fkey" FOREIGN KEY ("reported_account_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_bottle_id_fkey" FOREIGN KEY ("bottle_id") REFERENCES "bottles"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "conversations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_report_id_fkey" FOREIGN KEY ("report_id") REFERENCES "reports"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "moderation_tasks" ADD CONSTRAINT "moderation_tasks_assigned_to_id_fkey" FOREIGN KEY ("assigned_to_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_issued_by_id_fkey" FOREIGN KEY ("issued_by_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sanctions" ADD CONSTRAINT "sanctions_moderation_task_id_fkey" FOREIGN KEY ("moderation_task_id") REFERENCES "moderation_tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "accounts"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2cdb8f0 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,2 @@ +# Please do not edit this file manually +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..fce72d0 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,386 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum AccountStatus { + ACTIVE + SUSPENDED + DELETED +} + +enum AuthProvider { + PHONE + APPLE + WECHAT +} + +enum BottleStatus { + DRAFT + IN_POOL + PICKED + EXPIRED + REMOVED +} + +enum ConversationStatus { + ACTIVE + CLOSED +} + +enum MessageStatus { + SENT + DELETED + MODERATED +} + +enum ReportStatus { + PENDING + REVIEWING + RESOLVED + DISMISSED +} + +enum ModerationTaskStatus { + PENDING + PROCESSING + COMPLETED +} + +enum SanctionType { + WARNING + MUTE + SUSPENSION + BAN +} + +enum NotificationStatus { + PENDING + SENT + READ + FAILED +} + +enum OutboxStatus { + PENDING + PROCESSING + PUBLISHED + FAILED +} + +model Account { + id String @id @default(uuid()) @db.Uuid + phoneCiphertext Bytes @map("phone_ciphertext") + phoneHmac String @unique @map("phone_hmac") @db.VarChar(128) + status AccountStatus @default(ACTIVE) + tokenVersion Int @default(0) @map("token_version") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + anonymousProfile AnonymousProfile? + authIdentities AuthIdentity[] + sessions Session[] + bottles Bottle[] + pickLeases BottlePickLease[] @relation("PickerLeases") + pickHistory BottlePickHistory[] @relation("PickerHistory") + conversationMembers ConversationMember[] + messages Message[] + blocksInitiated Block[] @relation("Blocker") + blocksReceived Block[] @relation("Blocked") + reportsFiled Report[] @relation("Reporter") + reportsAgainst Report[] @relation("Reported") + moderationTasks ModerationTask[] @relation("Moderator") + sanctionsReceived Sanction[] @relation("SanctionedAccount") + sanctionsIssued Sanction[] @relation("SanctionIssuer") + notifications Notification[] + auditLogs AuditLog[] @relation("AuditActor") + dailyUsage DailyUsage[] + + @@map("accounts") +} + +model AnonymousProfile { + id String @id @default(uuid()) @db.Uuid + accountId String @unique @map("account_id") @db.Uuid + nickname String @db.VarChar(64) + avatarKey String? @map("avatar_key") @db.VarChar(255) + bio String? @db.VarChar(500) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@map("anonymous_profiles") +} + +model AuthIdentity { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + provider AuthProvider + providerSubject String @map("provider_subject") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([provider, providerSubject]) + @@index([accountId]) + @@map("auth_identities") +} + +model Session { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + refreshTokenHash String @unique @map("refresh_token_hash") @db.VarChar(255) + deviceId String? @map("device_id") @db.VarChar(255) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@index([accountId, expiresAt]) + @@map("sessions") +} + +model Bottle { + id String @id @default(uuid()) @db.Uuid + authorId String @map("author_id") @db.Uuid + contentText String @map("content_text") @db.Text + status BottleStatus @default(DRAFT) + pickedAt DateTime? @map("picked_at") @db.Timestamptz(3) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + author Account @relation(fields: [authorId], references: [id], onDelete: Restrict) + leases BottlePickLease[] + pickHistory BottlePickHistory[] + conversation Conversation? + reports Report[] + + @@index([status, createdAt]) + @@index([authorId, createdAt]) + @@map("bottles") +} + +model BottlePickLease { + id String @id @default(uuid()) @db.Uuid + bottleId String @map("bottle_id") @db.Uuid + pickerId String @map("picker_id") @db.Uuid + leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255) + expiresAt DateTime @map("expires_at") @db.Timestamptz(3) + consumedAt DateTime? @map("consumed_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade) + picker Account @relation("PickerLeases", fields: [pickerId], references: [id], onDelete: Cascade) + + @@index([pickerId, expiresAt]) + @@index([bottleId, expiresAt]) + @@map("bottle_pick_leases") +} + +model BottlePickHistory { + id String @id @default(uuid()) @db.Uuid + bottleId String @map("bottle_id") @db.Uuid + pickerId String @map("picker_id") @db.Uuid + pickedAt DateTime @default(now()) @map("picked_at") @db.Timestamptz(3) + bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade) + picker Account @relation("PickerHistory", fields: [pickerId], references: [id], onDelete: Cascade) + + @@unique([bottleId, pickerId]) + @@index([pickerId, pickedAt]) + @@map("bottle_pick_history") +} + +model DailyUsage { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + usageDate DateTime @map("usage_date") @db.Date + bottlesCreated Int @default(0) @map("bottles_created") + bottlesPicked Int @default(0) @map("bottles_picked") + messagesSent Int @default(0) @map("messages_sent") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([accountId, usageDate]) + @@index([usageDate]) + @@map("daily_usage") +} + +model Conversation { + id String @id @default(uuid()) @db.Uuid + sourceBottleId String @unique @map("source_bottle_id") @db.Uuid + status ConversationStatus @default(ACTIVE) + nextSeq Int @default(1) @map("next_seq") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + sourceBottle Bottle @relation(fields: [sourceBottleId], references: [id], onDelete: Restrict) + members ConversationMember[] + messages Message[] + reports Report[] + + @@index([status, updatedAt]) + @@map("conversations") +} + +model ConversationMember { + id String @id @default(uuid()) @db.Uuid + conversationId String @map("conversation_id") @db.Uuid + accountId String @map("account_id") @db.Uuid + joinedAt DateTime @default(now()) @map("joined_at") @db.Timestamptz(3) + leftAt DateTime? @map("left_at") @db.Timestamptz(3) + lastReadSeq Int @default(0) @map("last_read_seq") + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@unique([conversationId, accountId]) + @@index([accountId, joinedAt]) + @@map("conversation_members") +} + +model Message { + id String @id @default(uuid()) @db.Uuid + conversationId String @map("conversation_id") @db.Uuid + senderId String @map("sender_id") @db.Uuid + clientMsgId String @map("client_msg_id") @db.VarChar(128) + seq Int + contentText String @map("content_text") @db.Text + status MessageStatus @default(SENT) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + sender Account @relation(fields: [senderId], references: [id], onDelete: Restrict) + reports Report[] + + @@unique([conversationId, clientMsgId]) + @@unique([conversationId, seq]) + @@index([senderId, createdAt]) + @@map("messages") +} + +model Block { + id String @id @default(uuid()) @db.Uuid + blockerId String @map("blocker_id") @db.Uuid + blockedId String @map("blocked_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + blocker Account @relation("Blocker", fields: [blockerId], references: [id], onDelete: Cascade) + blocked Account @relation("Blocked", fields: [blockedId], references: [id], onDelete: Cascade) + + @@unique([blockerId, blockedId]) + @@index([blockedId]) + @@map("blocks") +} + +model Report { + id String @id @default(uuid()) @db.Uuid + reporterId String @map("reporter_id") @db.Uuid + reportedAccountId String? @map("reported_account_id") @db.Uuid + bottleId String? @map("bottle_id") @db.Uuid + conversationId String? @map("conversation_id") @db.Uuid + messageId String? @map("message_id") @db.Uuid + reason String @db.VarChar(100) + details String? @db.Text + status ReportStatus @default(PENDING) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + reporter Account @relation("Reporter", fields: [reporterId], references: [id], onDelete: Restrict) + reportedAccount Account? @relation("Reported", fields: [reportedAccountId], references: [id], onDelete: SetNull) + bottle Bottle? @relation(fields: [bottleId], references: [id], onDelete: SetNull) + conversation Conversation? @relation(fields: [conversationId], references: [id], onDelete: SetNull) + message Message? @relation(fields: [messageId], references: [id], onDelete: SetNull) + moderationTasks ModerationTask[] + + @@index([status, createdAt]) + @@index([reportedAccountId, createdAt]) + @@map("reports") +} + +model ModerationTask { + id String @id @default(uuid()) @db.Uuid + reportId String @map("report_id") @db.Uuid + assignedToId String? @map("assigned_to_id") @db.Uuid + status ModerationTaskStatus @default(PENDING) + decision String? @db.Text + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + report Report @relation(fields: [reportId], references: [id], onDelete: Cascade) + assignedTo Account? @relation("Moderator", fields: [assignedToId], references: [id], onDelete: SetNull) + sanctions Sanction[] + + @@index([status, createdAt]) + @@index([assignedToId, status]) + @@map("moderation_tasks") +} + +model Sanction { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + issuedById String? @map("issued_by_id") @db.Uuid + moderationTaskId String? @map("moderation_task_id") @db.Uuid + type SanctionType + reason String @db.Text + startsAt DateTime @default(now()) @map("starts_at") @db.Timestamptz(3) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + revokedAt DateTime? @map("revoked_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + account Account @relation("SanctionedAccount", fields: [accountId], references: [id], onDelete: Cascade) + issuedBy Account? @relation("SanctionIssuer", fields: [issuedById], references: [id], onDelete: SetNull) + moderationTask ModerationTask? @relation(fields: [moderationTaskId], references: [id], onDelete: SetNull) + + @@index([accountId, expiresAt]) + @@map("sanctions") +} + +model Notification { + id String @id @default(uuid()) @db.Uuid + accountId String @map("account_id") @db.Uuid + type String @db.VarChar(100) + payload Json + status NotificationStatus @default(PENDING) + sentAt DateTime? @map("sent_at") @db.Timestamptz(3) + readAt DateTime? @map("read_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) + + @@index([accountId, status, createdAt]) + @@map("notifications") +} + +model OutboxEvent { + id String @id @default(uuid()) @db.Uuid + aggregateType String @map("aggregate_type") @db.VarChar(100) + aggregateId String @map("aggregate_id") @db.Uuid + eventType String @map("event_type") @db.VarChar(150) + payload Json + status OutboxStatus @default(PENDING) + attempts Int @default(0) + availableAt DateTime @default(now()) @map("available_at") @db.Timestamptz(3) + publishedAt DateTime? @map("published_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + + @@index([status, availableAt]) + @@index([aggregateType, aggregateId]) + @@map("outbox_events") +} + +model AuditLog { + id String @id @default(uuid()) @db.Uuid + actorId String? @map("actor_id") @db.Uuid + action String @db.VarChar(150) + entityType String @map("entity_type") @db.VarChar(100) + entityId String? @map("entity_id") @db.Uuid + requestId String? @map("request_id") @db.VarChar(128) + ipHash String? @map("ip_hash") @db.VarChar(128) + metadata Json? + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + actor Account? @relation("AuditActor", fields: [actorId], references: [id], onDelete: SetNull) + + @@index([actorId, createdAt]) + @@index([entityType, entityId, createdAt]) + @@index([requestId]) + @@map("audit_logs") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..90770ae --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,63 @@ +import { createHash } from "node:crypto"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); +const id = (value: string) => createHash("sha256").update(value).digest("hex"); + +async function seed(): Promise { + const accounts = await Promise.all( + [ + { key: "demo-a", nickname: "海风" }, + { key: "demo-b", nickname: "星河" }, + ].map(({ key, nickname }) => + prisma.account.upsert({ + where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) }, + update: { + anonymousProfile: { + upsert: { create: { nickname }, update: { nickname } }, + }, + }, + create: { + phoneCiphertext: Buffer.from( + id(`drift-bottle:${key}:ciphertext`), + "hex", + ), + phoneHmac: id(`drift-bottle:${key}:phone-hmac`), + anonymousProfile: { create: { nickname } }, + }, + }), + ), + ); + + const author = accounts[0]; + if (!author) throw new Error("Seed author was not created"); + + await prisma.bottle.upsert({ + where: { id: "00000000-0000-4000-8000-000000000101" }, + update: { + authorId: author.id, + contentText: "愿你今天遇见温柔。", + status: "IN_POOL", + }, + create: { + id: "00000000-0000-4000-8000-000000000101", + authorId: author.id, + contentText: "愿你今天遇见温柔。", + status: "IN_POOL", + }, + }); + + console.info( + "Seed complete: 2 demo accounts, 2 anonymous profiles, 1 in-pool bottle.", + ); +} + +seed() + .catch((error: unknown) => { + console.error( + "Seed failed", + error instanceof Error ? error.message : "unknown error", + ); + process.exitCode = 1; + }) + .finally(async () => prisma.$disconnect()); diff --git a/tests/integration/database.spec.ts b/tests/integration/database.spec.ts new file mode 100644 index 0000000..c0bef07 --- /dev/null +++ b/tests/integration/database.spec.ts @@ -0,0 +1,146 @@ +import { Prisma, PrismaClient } from "@prisma/client"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { PrismaService } from "../../apps/api/src/database/prisma.service"; + +const databaseUrl = + process.env.DATABASE_URL ?? + "postgresql://drift:drift_test_only@127.0.0.1:55432/drift_bottle_test?schema=public"; +const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); + +async function expectUniqueViolation(operation: Promise) { + await expect(operation).rejects.toMatchObject({ + code: "P2002", + } satisfies Partial); +} + +async function createAccount(suffix: string) { + return prisma.account.create({ + data: { + phoneCiphertext: Buffer.from(`ciphertext-${suffix}`), + phoneHmac: `hmac-${suffix}`, + anonymousProfile: { create: { nickname: `漂友-${suffix}` } }, + }, + }); +} + +async function createBottle(authorId: string, suffix: string) { + return prisma.bottle.create({ + data: { authorId, contentText: `测试瓶子-${suffix}`, status: "IN_POOL" }, + }); +} + +describe("database authority constraints", () => { + beforeAll(async () => prisma.$connect()); + beforeEach(async () => { + await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`); + }); + afterAll(async () => prisma.$disconnect()); + + it("rejects a second conversation for the same source bottle with P2002", async () => { + const author = await createAccount("conversation-author"); + const bottle = await createBottle(author.id, "conversation"); + await prisma.conversation.create({ data: { sourceBottleId: bottle.id } }); + + await expectUniqueViolation( + prisma.conversation.create({ data: { sourceBottleId: bottle.id } }), + ); + }); + + it("rejects duplicate bottle pick history with P2002", async () => { + const author = await createAccount("history-author"); + const picker = await createAccount("history-picker"); + const bottle = await createBottle(author.id, "history"); + await prisma.bottlePickHistory.create({ + data: { bottleId: bottle.id, pickerId: picker.id }, + }); + + await expectUniqueViolation( + prisma.bottlePickHistory.create({ + data: { bottleId: bottle.id, pickerId: picker.id }, + }), + ); + }); + + it("rejects duplicate directed blocks with P2002", async () => { + const blocker = await createAccount("blocker"); + const blocked = await createAccount("blocked"); + await prisma.block.create({ + data: { blockerId: blocker.id, blockedId: blocked.id }, + }); + + await expectUniqueViolation( + prisma.block.create({ + data: { blockerId: blocker.id, blockedId: blocked.id }, + }), + ); + }); + + it("rejects duplicate client message ids within a conversation with P2002", async () => { + const author = await createAccount("client-message-author"); + const bottle = await createBottle(author.id, "client-message"); + const conversation = await prisma.conversation.create({ + data: { sourceBottleId: bottle.id }, + }); + await prisma.message.create({ + data: { + conversationId: conversation.id, + senderId: author.id, + clientMsgId: "client-1", + seq: 1, + contentText: "一", + }, + }); + + await expectUniqueViolation( + prisma.message.create({ + data: { + conversationId: conversation.id, + senderId: author.id, + clientMsgId: "client-1", + seq: 2, + contentText: "二", + }, + }), + ); + }); + + it("rejects duplicate message sequences within a conversation with P2002", async () => { + const author = await createAccount("sequence-author"); + const bottle = await createBottle(author.id, "sequence"); + const conversation = await prisma.conversation.create({ + data: { sourceBottleId: bottle.id }, + }); + await prisma.message.create({ + data: { + conversationId: conversation.id, + senderId: author.id, + clientMsgId: "client-a", + seq: 1, + contentText: "一", + }, + }); + + await expectUniqueViolation( + prisma.message.create({ + data: { + conversationId: conversation.id, + senderId: author.id, + clientMsgId: "client-b", + seq: 1, + contentText: "二", + }, + }), + ); + }); +}); + +describe("PrismaService lifecycle", () => { + it("connects and disconnects through module lifecycle hooks", async () => { + const service = new PrismaService({ + datasources: { db: { url: databaseUrl } }, + }); + await service.onModuleInit(); + await expect(service.$queryRaw`SELECT 1`).resolves.toBeDefined(); + await service.onModuleDestroy(); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index cf3b9e0..65251a7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -10,5 +10,10 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true }, - "include": ["packages/**/*.ts"] + "include": [ + "packages/**/*.ts", + "apps/**/*.ts", + "prisma/**/*.ts", + "tests/**/*.ts" + ] }