feat: 建立漂流瓶权威数据模型
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
+20
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+795
-8
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
# Please do not edit this file manually
|
||||
provider = "postgresql"
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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<void> {
|
||||
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());
|
||||
@@ -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<unknown>) {
|
||||
await expect(operation).rejects.toMatchObject({
|
||||
code: "P2002",
|
||||
} satisfies Partial<Prisma.PrismaClientKnownRequestError>);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+6
-1
@@ -10,5 +10,10 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["packages/**/*.ts"]
|
||||
"include": [
|
||||
"packages/**/*.ts",
|
||||
"apps/**/*.ts",
|
||||
"prisma/**/*.ts",
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user