commit 36f466ba11e2c6d8751b54a554256ea03ad3b3de Author: youbin Date: Fri Jul 31 14:21:43 2026 +0800 Release v1.0.0 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bdb785d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.env +.env.* +!.env.example +!.env.production.example +node_modules +server/node_modules +dist +server/dist +coverage +backups +tmp +*.log +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1e4e432 --- /dev/null +++ b/.env.example @@ -0,0 +1,95 @@ +# Application +NODE_ENV=development +API_HOST=127.0.0.1 +API_PORT=8787 +API_PUBLIC_ORIGIN=http://127.0.0.1:8787 +WEB_ORIGIN=http://127.0.0.1:4173 +LOG_LEVEL=info +METRICS_HOST=127.0.0.1 +WORKER_METRICS_PORT=9092 +WORKER_HEARTBEAT_KEY=frameflow:worker:heartbeat +WORKER_HEARTBEAT_INTERVAL_MS=5000 +WORKER_HEARTBEAT_TTL_SECONDS=20 +METRICS_TOKEN= + +# Security - replace every secret outside local development +JWT_ACCESS_SECRET=replace-with-at-least-32-characters +JWT_REFRESH_SECRET=replace-with-another-32-character-secret +CREDENTIAL_ENCRYPTION_KEY=replace-with-an-independent-32-character-secret +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL_DAYS=30 +EMAIL_VERIFICATION_REQUIRED=false +EMAIL_VERIFICATION_TTL_HOURS=24 +PASSWORD_RESET_TTL_MINUTES=30 +# Verification, password-reset, and workspace-invitation email delivery. +RESEND_API_KEY= +EMAIL_FROM=FrameFlow + +# PostgreSQL / Redis +DATABASE_URL=postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow +REDIS_URL=redis://127.0.0.1:63799 + +# S3-compatible object storage +S3_ENDPOINT=http://127.0.0.1:9000 +# Optional browser/provider-facing endpoint. Defaults to S3_ENDPOINT locally. +S3_PUBLIC_ENDPOINT=http://127.0.0.1:9000 +S3_REGION=us-east-1 +S3_BUCKET=frameflow +S3_ACCESS_KEY=frameflow +S3_SECRET_KEY=frameflow-local-secret +S3_FORCE_PATH_STYLE=true + +# AI providers (optional until configured) +OPENAI_API_KEY= +OPENAI_TEXT_MODEL=gpt-5.6-sol +OPENAI_IMAGE_MODEL=gpt-image-2 +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +# Agnes OpenAI-compatible gateway (text, image and video) +AGNES_API_KEY= +AGNES_TEXT_MODEL=agnes-2.5-flash +AGNES_IMAGE_MODEL=agnes-image-2.1-flash +AGNES_VIDEO_MODEL=agnes-video-v2.0 +AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS=900 +AGNES_VIDEO_POLL_INTERVAL_MS=4000 +# Generic OpenAI-compatible providers. Each capability is configured independently. +# BASE_URL is the API root including its version path, for example https://api.vendor.com/v1. +GENERIC_TEXT_API_KEY= +GENERIC_TEXT_BASE_URL= +GENERIC_TEXT_MODEL= +GENERIC_IMAGE_API_KEY= +GENERIC_IMAGE_BASE_URL= +GENERIC_IMAGE_MODEL= +GENERIC_VIDEO_API_KEY= +GENERIC_VIDEO_BASE_URL= +GENERIC_VIDEO_MODEL= +GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS=900 +GENERIC_VIDEO_POLL_INTERVAL_MS=4000 +REPLICATE_API_TOKEN= +REPLICATE_IMAGE_VERSION= +REPLICATE_VIDEO_VERSION= +REPLICATE_LIPSYNC_VERSION= +# Replicate cancels predictions that have not finished within this deadline. +REPLICATE_PREDICTION_DEADLINE_SECONDS=900 + +# Stripe subscriptions (optional; the UI reports missing configuration) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRO_PRICE_ID= +STRIPE_STUDIO_PRICE_ID= +STRIPE_PORTAL_CONFIGURATION_ID= +BILLING_SUCCESS_URL= +BILLING_CANCEL_URL= + +# External publishing connector. Leave both empty until a connector is deployed. +PUBLISHING_CONNECTOR_URL= +PUBLISHING_CONNECTOR_SECRET= +PUBLISHING_CONNECTOR_TIMEOUT_MS=30000 + +# Media worker +FFMPEG_PATH=ffmpeg +FFPROBE_PATH=ffprobe +WORKER_CONCURRENCY=2 +MAX_UPLOAD_BYTES=104857600 +PROVIDER_DOWNLOAD_TIMEOUT_MS=180000 +PROVIDER_DOWNLOAD_MAX_REDIRECTS=3 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..2eb0232 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,108 @@ +# Public routing. Terminate TLS in front of HTTP_PORT and preserve the Host header. +APP_HOST=studio.example.com +MEDIA_HOST=media.example.com +WEB_ORIGIN=https://studio.example.com +API_PUBLIC_ORIGIN=https://studio.example.com +S3_PUBLIC_ENDPOINT=https://media.example.com +HTTP_BIND=0.0.0.0 +HTTP_PORT=8080 +MAX_UPLOAD_SIZE=100m + +# Image/version naming and local-only operator ports. +COMPOSE_PROJECT_NAME=frameflow-prod +FRAMEFLOW_VERSION=latest +MINIO_CONSOLE_BIND=127.0.0.1 +MINIO_CONSOLE_PORT=9001 +PROMETHEUS_BIND=127.0.0.1 +PROMETHEUS_PORT=9090 +PROMETHEUS_RETENTION=30d +# Host directory shared by backup.sh and Node Exporter's textfile collector. +FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics +ALERTMANAGER_BIND=127.0.0.1 +ALERTMANAGER_PORT=9093 +# Replace with the operator endpoint that receives firing and resolved alerts. +ALERTMANAGER_WEBHOOK_URL=https://alerts.example.com/frameflow-webhook + +# Replace every value below before deployment. Keep URL passwords URL-safe. +POSTGRES_DB=frameflow +POSTGRES_USER=frameflow +POSTGRES_PASSWORD=replace_with_strong_postgres_password +DATABASE_URL=postgresql://frameflow:replace_with_strong_postgres_password@postgres:5432/frameflow +REDIS_PASSWORD=replace_with_strong_redis_password +REDIS_URL=redis://default:replace_with_strong_redis_password@redis:6379 +JWT_ACCESS_SECRET=replace_with_at_least_32_random_characters_access +JWT_REFRESH_SECRET=replace_with_at_least_32_random_characters_refresh +CREDENTIAL_ENCRYPTION_KEY=replace_with_independent_credential_encryption_key +S3_BUCKET=frameflow +S3_REGION=us-east-1 +S3_ACCESS_KEY=replace_with_minio_access_key +S3_SECRET_KEY=replace_with_minio_secret_key + +# Metrics stay on the private Compose network. Set a token only when the +# Prometheus scrape configuration is updated to send the same token. +METRICS_TOKEN= +LOG_LEVEL=info +ACCESS_TOKEN_TTL=15m +REFRESH_TOKEN_TTL_DAYS=30 +EMAIL_VERIFICATION_REQUIRED=true +EMAIL_VERIFICATION_TTL_HOURS=24 +PASSWORD_RESET_TTL_MINUTES=30 +# Used for verification, password-reset, and workspace-invitation emails. +RESEND_API_KEY=replace_with_resend_api_key +EMAIL_FROM=FrameFlow + +# AI providers. Missing values fail jobs explicitly and never create fake media. +OPENAI_API_KEY= +OPENAI_TEXT_MODEL=gpt-5.6-sol +OPENAI_IMAGE_MODEL=gpt-image-2 +OPENAI_TTS_MODEL=gpt-4o-mini-tts +OPENAI_TTS_VOICE=alloy +# Agnes OpenAI-compatible gateway (text, image and video) +AGNES_API_KEY= +AGNES_TEXT_MODEL=agnes-2.5-flash +AGNES_IMAGE_MODEL=agnes-image-2.1-flash +AGNES_VIDEO_MODEL=agnes-video-v2.0 +AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS=900 +AGNES_VIDEO_POLL_INTERVAL_MS=4000 +# Generic OpenAI-compatible providers. Configure each capability as a complete triple. +# BASE_URL is the API root including its version path, for example https://api.vendor.com/v1. +GENERIC_TEXT_API_KEY= +GENERIC_TEXT_BASE_URL= +GENERIC_TEXT_MODEL= +GENERIC_IMAGE_API_KEY= +GENERIC_IMAGE_BASE_URL= +GENERIC_IMAGE_MODEL= +GENERIC_VIDEO_API_KEY= +GENERIC_VIDEO_BASE_URL= +GENERIC_VIDEO_MODEL= +GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS=900 +GENERIC_VIDEO_POLL_INTERVAL_MS=4000 +REPLICATE_API_TOKEN= +REPLICATE_IMAGE_VERSION= +REPLICATE_VIDEO_VERSION= +REPLICATE_LIPSYNC_VERSION= +# Sent to Replicate as Cancel-After; valid range is 5 seconds to 24 hours. +REPLICATE_PREDICTION_DEADLINE_SECONDS=900 + +# Stripe subscriptions. Leave all values empty to keep billing read-only. +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRO_PRICE_ID= +STRIPE_STUDIO_PRICE_ID= +STRIPE_PORTAL_CONFIGURATION_ID= +BILLING_SUCCESS_URL=https://studio.example.com +BILLING_CANCEL_URL=https://studio.example.com + +# Optional platform connector for Douyin, Kuaishou, Bilibili, and Xiaohongshu. +# Configure both values together. The connector must expose the documented /v1 endpoints. +PUBLISHING_CONNECTOR_URL= +PUBLISHING_CONNECTOR_SECRET= +PUBLISHING_CONNECTOR_TIMEOUT_MS=30000 + +WORKER_CONCURRENCY=2 +WORKER_HEARTBEAT_KEY=frameflow:worker:heartbeat +WORKER_HEARTBEAT_INTERVAL_MS=5000 +WORKER_HEARTBEAT_TTL_SECONDS=20 +MAX_UPLOAD_BYTES=104857600 +PROVIDER_DOWNLOAD_TIMEOUT_MS=180000 +PROVIDER_DOWNLOAD_MAX_REDIRECTS=3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..79e4417 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,176 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frameflow-ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Migrations, tests, build, and audit + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + NODE_ENV: test + WEB_ORIGIN: http://127.0.0.1:4173 + DATABASE_URL: postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow + REDIS_URL: redis://127.0.0.1:63799 + JWT_ACCESS_SECRET: ci-access-secret-with-at-least-32-characters + JWT_REFRESH_SECRET: ci-refresh-secret-with-at-least-32-characters + EMAIL_VERIFICATION_REQUIRED: "false" + S3_ENDPOINT: http://127.0.0.1:9000 + S3_PUBLIC_ENDPOINT: http://127.0.0.1:9000 + S3_REGION: us-east-1 + S3_BUCKET: frameflow + S3_ACCESS_KEY: frameflow + S3_SECRET_KEY: frameflow-local-secret + S3_FORCE_PATH_STYLE: "true" + FFMPEG_PATH: ffmpeg + FFPROBE_PATH: ffprobe + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install system media dependencies + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg fonts-noto-cjk + + - name: Install dependencies + run: npm ci + + - name: Validate production smoke-test CLI + run: npm run smoke:production -- --help + + - name: Validate Alertmanager smoke-test CLI + run: npm run smoke:alertmanager -- --help + + - name: Validate Stripe smoke-test CLI + run: npm run smoke:stripe -- --help + + - name: Validate isolated restore-rehearsal CLI + run: npm run restore:rehearse -- --help + + - name: Validate scheduled maintenance CLI and systemd units + run: | + ./scripts/scheduled-maintenance.sh --help + systemd-analyze verify deploy/systemd/*.service deploy/systemd/*.timer + + - name: Start test infrastructure + run: | + docker compose up -d --wait postgres redis minio + docker compose run --rm --no-deps minio-init + + - name: Apply database migrations + run: npm run db:migrate + + - name: Run test suite + run: npm test + + - name: Create and rehearse a real isolated restore + shell: bash + env: + COMPOSE_PROJECT_NAME: frameflow + FRAMEFLOW_ENV_FILE: ${{ github.workspace }}/.env.production.example + FRAMEFLOW_BACKUP_METRICS_DIR: ${{ runner.temp }}/frameflow-metrics + FRAMEFLOW_RESTORE_METRICS_DIR: ${{ runner.temp }}/frameflow-metrics + run: | + backup_parent="${RUNNER_TEMP}/frameflow-backups" + ./scripts/backup.sh "${backup_parent}" + backup_dir="$(find "${backup_parent}" -mindepth 1 -maxdepth 1 -type d -name 'frameflow-*' -print -quit)" + test -n "${backup_dir}" + npm run restore:rehearse -- "${backup_dir}" + + - name: Build web and server + run: npm run build:all + + - name: Audit production dependencies + run: npm audit --omit=dev + + - name: Show infrastructure diagnostics + if: failure() + run: | + docker compose ps + docker compose logs --no-color --tail=200 postgres redis minio + + - name: Stop test infrastructure + if: always() + run: docker compose down --volumes --remove-orphans + + production-images: + name: Production gate and container images + runs-on: ubuntu-24.04 + timeout-minutes: 30 + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Create synthetic production configuration + shell: bash + run: | + production_env="${RUNNER_TEMP}/frameflow-production.env" + { + echo "APP_HOST=studio.frameflow-ci.test" + echo "MEDIA_HOST=media.frameflow-ci.test" + echo "WEB_ORIGIN=https://studio.frameflow-ci.test" + echo "API_PUBLIC_ORIGIN=https://studio.frameflow-ci.test" + echo "S3_PUBLIC_ENDPOINT=https://media.frameflow-ci.test" + echo "COMPOSE_PROJECT_NAME=frameflow-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "FRAMEFLOW_VERSION=ci" + echo "ALERTMANAGER_WEBHOOK_URL=https://alerts.frameflow-ci.test/webhook" + echo "POSTGRES_DB=frameflow" + echo "POSTGRES_USER=frameflow" + echo "POSTGRES_PASSWORD=ci-postgres-password-with-32-characters" + echo "DATABASE_URL=postgresql://frameflow:ci-postgres-password-with-32-characters@postgres:5432/frameflow" + echo "REDIS_PASSWORD=ci-redis-password-with-32-characters" + echo "REDIS_URL=redis://default:ci-redis-password-with-32-characters@redis:6379" + echo "JWT_ACCESS_SECRET=ci-access-signing-secret-with-32-characters" + echo "JWT_REFRESH_SECRET=ci-refresh-signing-secret-with-32-characters" + echo "CREDENTIAL_ENCRYPTION_KEY=ci-credential-encryption-secret-with-32-characters" + echo "S3_BUCKET=frameflow" + echo "S3_ACCESS_KEY=frameflow-ci-access-key" + echo "S3_SECRET_KEY=frameflow-ci-storage-secret-with-32-characters" + echo "EMAIL_VERIFICATION_REQUIRED=true" + echo "RESEND_API_KEY=re_ci_configuration_validation_only" + echo "EMAIL_FROM=FrameFlow CI " + echo "BILLING_SUCCESS_URL=https://studio.frameflow-ci.test" + echo "BILLING_CANCEL_URL=https://studio.frameflow-ci.test" + } > "${production_env}" + echo "PRODUCTION_ENV=${production_env}" >> "${GITHUB_ENV}" + + - name: Validate production Compose configuration + run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml config --quiet + + - name: Build production images + run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml build config-check web + + - name: Accept a safe production configuration + run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml run --rm --no-deps config-check + + - name: Reject the placeholder production example + shell: bash + run: | + if COMPOSE_PROJECT_NAME="frameflow-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" FRAMEFLOW_VERSION=ci docker compose --env-file .env.production.example -f docker-compose.prod.yml run --rm --no-deps config-check; then + echo "The production gate accepted placeholder configuration." >&2 + exit 1 + fi + + - name: Remove build containers + if: always() + run: docker compose --env-file "${PRODUCTION_ENV}" -f docker-compose.prod.yml down --remove-orphans diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc7a938 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +server/dist/ +.env +.env.local +*.log +.DS_Store +coverage/ +.turbo/ +tmp/ +backups/ +.env.production diff --git a/Dockerfile.server b/Dockerfile.server new file mode 100644 index 0000000..b063d18 --- /dev/null +++ b/Dockerfile.server @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24-bookworm-slim AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY server/package.json ./server/package.json +RUN npm ci --workspace @frameflow/server --include-workspace-root=false + +COPY server ./server +RUN npm run build --workspace @frameflow/server + +FROM node:24-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app + +RUN DEBIAN_FRONTEND=noninteractive apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::ForceIPv4=true \ + -o Acquire::http::Timeout=30 \ + -o Acquire::http::Pipeline-Depth=0 \ + -o Acquire::http::No-Cache=true \ + -o Acquire::BrokenProxy=true \ + -o Acquire::https::Timeout=30 \ + update \ + && DEBIAN_FRONTEND=noninteractive apt-get \ + -o Acquire::Retries=3 \ + -o Acquire::ForceIPv4=true \ + -o Acquire::http::Timeout=30 \ + -o Acquire::http::Pipeline-Depth=0 \ + -o Acquire::http::No-Cache=true \ + -o Acquire::BrokenProxy=true \ + -o Acquire::https::Timeout=30 \ + install -y --no-install-recommends ca-certificates dumb-init ffmpeg fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +COPY server/package.json ./server/package.json +RUN npm ci --omit=dev --workspace @frameflow/server --include-workspace-root=false \ + && npm cache clean --force + +COPY --from=build --chown=node:node /app/server/dist ./server/dist +COPY --chown=node:node server/drizzle ./server/drizzle + +USER node +EXPOSE 8787 9092 +ENTRYPOINT ["dumb-init", "--"] +CMD ["node", "server/dist/index.js"] diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..8697f93 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1.7 + +FROM node:24-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY server/package.json ./server/package.json +RUN npm ci --include-workspace-root + +COPY index.html ./ +COPY src ./src +COPY public ./public + +ARG VITE_API_URL=/api/v1 +ENV VITE_API_URL=${VITE_API_URL} +RUN npm run build + +FROM nginx:1.29-alpine AS runtime +COPY deploy/nginx/default.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=5 \ + CMD wget -qO- http://127.0.0.1/health/live >/dev/null || exit 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a4c371 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# FrameFlow AI Comic Studio + +FrameFlow is an end-to-end AI comic drama production workspace. It includes project and episode management, reusable character and scene assets, AI script and storyboard jobs, shot production, TTS/video/lipsync processing, FFmpeg rendering with immutable source manifests and SRT/VTT sidecars, immutable versions, review workflows, user-scoped persistent notifications with authenticated realtime updates, email-backed workspace invitations, delivery, usage quotas, persisted administrator-only AI provider connection verification, Prometheus metrics, infrastructure capacity and backup-age alerts, and Alertmanager webhook notifications. + +## Local development + +Requirements: Node.js 24+, Docker Compose, and FFmpeg. + +```bash +cp .env.example .env +npm ci +npm run infra:up +npm run db:migrate +npm run db:seed +npm run dev +``` + +The web app runs at `http://127.0.0.1:5173` by default. The repository's current preview setup may use port `4173` instead. The API readiness endpoint is `http://127.0.0.1:8787/health/ready`; it also verifies that the generation worker heartbeat is current. + +Cloud jobs require valid OpenAI and Replicate configuration. Missing provider credentials cause explicit failed jobs; the system does not substitute generated demo output. + +Publishing to Douyin, Kuaishou, Bilibili, and Xiaohongshu requires a separately deployed connector that owns each platform's application credentials and API-specific behavior. FrameFlow stores encrypted channel tokens, schedules delivery jobs, and verifies signed status callbacks. The connector HTTP contract, environment variables, and live acceptance checklist are documented in [docs/deployment.md](docs/deployment.md#publishing-connector). + +## Verification + +```bash +npm run build:all +npm test +``` + +The GitHub Actions workflow in `.github/workflows/ci.yml` runs migrations, the complete web and server test suites, a real three-store backup and isolated restore rehearsal, production builds, a production-dependency audit, production Compose validation, container image builds, and both the accepting and rejecting sides of the production configuration gate. Its production values are synthetic and generated only inside the runner; no deploy credentials are stored in the workflow. + +After deployment, `npm run smoke:production` runs an authenticated, provider-free production path through persistence, object upload/download, BullMQ, FFmpeg rendering, subtitle sidecars, review approval, audit logs, and secure download delivery. The default mode reports AI provider configuration without starting billable jobs. A separate `--include-ai` mode performs live OpenAI and Replicate generation only after an exact billable-charge acknowledgement, complete seven-workflow readiness, and a 700-credit preflight. Required environment variables, provider-specific parameter handling, and data-retention behavior are documented in the deployment guide. + +Production deployment, TLS/media routing, monitoring, controlled firing/resolved Alertmanager webhook acceptance, read-only Stripe resource verification, backup, restore, and upgrade procedures are documented in [docs/deployment.md](docs/deployment.md). +The production Compose stack includes a fail-fast configuration gate before migrations; it rejects placeholder secrets, unsafe public endpoints, and incomplete provider or billing configuration without printing secret values. +Production backups are checksum-protected and pass a standalone read-only archive verifier before success metrics are published. A separate restore-rehearsal command restores all three data stores into randomly named, labeled Docker resources with no published ports, verifies their contents, publishes rehearsal metrics, and removes the isolated resources. Supplied hardened systemd timers run a verified backup daily and require a fresh successful backup before the monthly restore rehearsal; the scheduler refuses to use a missing or unexpected backup mount. diff --git a/deploy/alertmanager.yml.template b/deploy/alertmanager.yml.template new file mode 100644 index 0000000..1cc4797 --- /dev/null +++ b/deploy/alertmanager.yml.template @@ -0,0 +1,22 @@ +global: + resolve_timeout: 5m + +route: + receiver: frameflow-operator-webhook + group_by: [alertname, severity] + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + routes: + - receiver: frameflow-operator-webhook + matchers: + - alertname="FrameFlowDeliveryTest" + group_wait: 1s + group_interval: 5s + repeat_interval: 1h + +receivers: + - name: frameflow-operator-webhook + webhook_configs: + - url: "__ALERTMANAGER_WEBHOOK_URL__" + send_resolved: true diff --git a/deploy/alerts.yml b/deploy/alerts.yml new file mode 100644 index 0000000..5144aeb --- /dev/null +++ b/deploy/alerts.yml @@ -0,0 +1,156 @@ +groups: + - name: frameflow-availability + rules: + - alert: FrameFlowApiDown + expr: up{job="frameflow-api"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: FrameFlow API is down + description: Prometheus has been unable to scrape the API for two minutes. + + - alert: FrameFlowWorkerDown + expr: up{job="frameflow-worker"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: FrameFlow worker is down + description: Prometheus has been unable to scrape the generation worker for two minutes. + + - alert: FrameFlowDependencyUnavailable + expr: frameflow_application_dependency_available == 0 + for: 2m + labels: + severity: critical + annotations: + summary: FrameFlow dependency is unavailable + description: "API readiness dependency {{ $labels.dependency }} has been unavailable for two minutes." + + - alert: FrameFlowWorkerHeartbeatStale + expr: time() - frameflow_generation_worker_heartbeat_timestamp_seconds > 30 + for: 1m + labels: + severity: critical + annotations: + summary: FrameFlow worker heartbeat is stale + description: The worker metrics process is reachable but its Redis heartbeat has stopped. + + - alert: FrameFlowInfrastructureExporterDown + expr: up{job=~"frameflow-node|frameflow-minio"} == 0 + for: 5m + labels: + severity: critical + annotations: + summary: FrameFlow infrastructure metrics are unavailable + description: "Prometheus has been unable to scrape {{ $labels.job }} for five minutes." + + - name: frameflow-capacity-and-backup + rules: + - alert: FrameFlowHostFilesystemLow + expr: | + ( + node_filesystem_avail_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs|nsfs|tracefs|cgroup2?"} + / + node_filesystem_size_bytes{fstype!~"tmpfs|devtmpfs|overlay|squashfs|nsfs|tracefs|cgroup2?"} + ) < 0.15 + and on(instance, device, mountpoint) + node_filesystem_readonly == 0 + for: 15m + labels: + severity: warning + annotations: + summary: FrameFlow host filesystem has less than 15% free space + description: "Filesystem {{ $labels.mountpoint }} on {{ $labels.instance }} is running low on space." + + - alert: FrameFlowMinioCapacityLow + expr: | + minio_cluster_capacity_usable_free_bytes + / + minio_cluster_capacity_usable_total_bytes + < 0.15 + for: 15m + labels: + severity: warning + annotations: + summary: FrameFlow object storage has less than 15% usable capacity + description: "MinIO cluster {{ $labels.server }} is running low on usable object-storage capacity." + + - alert: FrameFlowBackupStale + expr: | + (time() - frameflow_backup_last_success_timestamp_seconds > 90000) + or absent(frameflow_backup_last_success_timestamp_seconds) + for: 15m + labels: + severity: critical + annotations: + summary: FrameFlow has no successful backup in the last 25 hours + description: Run and verify the scheduled PostgreSQL, Redis, and MinIO backup. + + - alert: FrameFlowRestoreRehearsalStale + expr: | + (time() - frameflow_restore_rehearsal_last_success_timestamp_seconds > 3024000) + or absent(frameflow_restore_rehearsal_last_success_timestamp_seconds) + for: 15m + labels: + severity: warning + annotations: + summary: FrameFlow has no successful restore rehearsal in the last 35 days + description: Restore a verified backup into the isolated rehearsal environment and investigate any validation failure. + + - name: frameflow-performance + rules: + - alert: FrameFlowApiHighErrorRate + expr: | + ( + sum(rate(frameflow_api_requests_total{status=~"5.."}[5m])) + / + clamp_min(sum(rate(frameflow_api_requests_total[5m])), 0.001) + ) > 0.05 + and sum(rate(frameflow_api_requests_total[5m])) > 0.05 + for: 10m + labels: + severity: warning + annotations: + summary: FrameFlow API error rate is high + description: More than 5% of API requests have returned 5xx responses for ten minutes. + + - alert: FrameFlowApiLatencyHigh + expr: | + histogram_quantile(0.95, + sum by (le) (rate(frameflow_api_request_duration_seconds_bucket[10m])) + ) > 2 + for: 10m + labels: + severity: warning + annotations: + summary: FrameFlow API latency is high + description: API p95 latency has exceeded two seconds for ten minutes. + + - alert: FrameFlowGenerationFailureRateHigh + expr: | + ( + sum(rate(frameflow_generation_jobs_processed_total{result="failed"}[10m])) + / + clamp_min(sum(rate(frameflow_generation_jobs_processed_total[10m])), 0.001) + ) > 0.20 + and sum(rate(frameflow_generation_jobs_processed_total[10m])) > 0.01 + for: 10m + labels: + severity: warning + annotations: + summary: FrameFlow generation failure rate is high + description: More than 20% of recent generation attempts have failed. + + - alert: FrameFlowQueueLatencyHigh + expr: | + histogram_quantile(0.95, + sum by (le) (rate(frameflow_generation_job_queue_delay_seconds_bucket[10m])) + ) > 60 + for: 10m + labels: + severity: warning + annotations: + summary: FrameFlow queue latency is high + description: Generation job p95 queue delay has exceeded 60 seconds for ten minutes. diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template new file mode 100644 index 0000000..0b711e3 --- /dev/null +++ b/deploy/nginx/default.conf.template @@ -0,0 +1,83 @@ +upstream frameflow_api { + server api:8787; + keepalive 32; +} + +server { + listen 80 default_server; + server_name ${APP_HOST}; + root /usr/share/nginx/html; + index index.html; + client_max_body_size ${MAX_UPLOAD_SIZE}; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), geolocation=(), microphone=()" always; + + location /api/ { + proxy_pass http://frameflow_api; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + } + + location /health/ { + proxy_pass http://frameflow_api; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /metrics { + return 404; + } + + location ~* \.(?:css|js|jpg|jpeg|png|webp|avif|svg|ico|woff2?)$ { + try_files $uri =404; + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + } + + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-cache"; + } +} + +server { + listen 80; + server_name ${MEDIA_HOST}; + client_max_body_size ${MAX_UPLOAD_SIZE}; + + location = /minio/v2/metrics { + return 404; + } + + location ^~ /minio/v2/metrics/ { + return 404; + } + + location = /minio/v3/metrics { + return 404; + } + + location ^~ /minio/v3/metrics/ { + return 404; + } + + location / { + proxy_pass http://minio:9000; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml new file mode 100644 index 0000000..945905d --- /dev/null +++ b/deploy/prometheus.yml @@ -0,0 +1,32 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: ["alertmanager:9093"] + +rule_files: + - /etc/prometheus/alerts.yml + +scrape_configs: + - job_name: frameflow-api + metrics_path: /metrics + static_configs: + - targets: ["api:8787"] + + - job_name: frameflow-worker + metrics_path: /metrics + static_configs: + - targets: ["worker:9092"] + + - job_name: frameflow-node + metrics_path: /metrics + static_configs: + - targets: ["node-exporter:9100"] + + - job_name: frameflow-minio + metrics_path: /minio/v2/metrics/cluster + static_configs: + - targets: ["minio:9000"] diff --git a/deploy/systemd/frameflow-backup.env.example b/deploy/systemd/frameflow-backup.env.example new file mode 100644 index 0000000..438aebc --- /dev/null +++ b/deploy/systemd/frameflow-backup.env.example @@ -0,0 +1,9 @@ +# Copy to /etc/frameflow/backup.env and keep it readable only by root and the +# FrameFlow service group. These paths must match the installed application, +# encrypted backup mount, and Node Exporter textfile collector configuration. +FRAMEFLOW_ENV_FILE=/srv/frameflow/.env.production +FRAMEFLOW_BACKUP_MOUNTPOINT=/mnt/encrypted-backups +FRAMEFLOW_BACKUP_DESTINATION=/mnt/encrypted-backups/frameflow +FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics +FRAMEFLOW_RESTORE_METRICS_DIR=/var/lib/frameflow/metrics +FRAMEFLOW_RESTORE_TIMEOUT_SECONDS=300 diff --git a/deploy/systemd/frameflow-backup.service b/deploy/systemd/frameflow-backup.service new file mode 100644 index 0000000..160eb77 --- /dev/null +++ b/deploy/systemd/frameflow-backup.service @@ -0,0 +1,35 @@ +[Unit] +Description=FrameFlow verified PostgreSQL, Redis, and MinIO backup +Documentation=file:///srv/frameflow/docs/deployment.md +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target +RequiresMountsFor=/mnt/encrypted-backups +AssertPathIsMountPoint=/mnt/encrypted-backups +ConditionPathExists=/srv/frameflow/scripts/scheduled-maintenance.sh +ConditionPathExists=/etc/frameflow/backup.env + +[Service] +Type=oneshot +User=frameflow +Group=frameflow +SupplementaryGroups=docker +EnvironmentFile=/etc/frameflow/backup.env +WorkingDirectory=/srv/frameflow +ExecStart=/srv/frameflow/scripts/scheduled-maintenance.sh backup +UMask=0077 +TimeoutStartSec=6h +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=6 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +ReadWritePaths=/mnt/encrypted-backups/frameflow /var/lib/frameflow/metrics /var/run/docker.sock diff --git a/deploy/systemd/frameflow-backup.timer b/deploy/systemd/frameflow-backup.timer new file mode 100644 index 0000000..202dec2 --- /dev/null +++ b/deploy/systemd/frameflow-backup.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run the FrameFlow verified backup every day + +[Timer] +OnCalendar=*-*-* 03:15:00 +RandomizedDelaySec=15m +AccuracySec=1m +Persistent=true +Unit=frameflow-backup.service + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/frameflow-restore-rehearsal.service b/deploy/systemd/frameflow-restore-rehearsal.service new file mode 100644 index 0000000..262bf59 --- /dev/null +++ b/deploy/systemd/frameflow-restore-rehearsal.service @@ -0,0 +1,36 @@ +[Unit] +Description=FrameFlow isolated restore rehearsal of the latest verified backup +Documentation=file:///srv/frameflow/docs/deployment.md +Requires=docker.service frameflow-backup.service +After=docker.service frameflow-backup.service network-online.target +Wants=network-online.target +RequiresMountsFor=/mnt/encrypted-backups +AssertPathIsMountPoint=/mnt/encrypted-backups +ConditionPathExists=/srv/frameflow/scripts/scheduled-maintenance.sh +ConditionPathExists=/etc/frameflow/backup.env + +[Service] +Type=oneshot +User=frameflow +Group=frameflow +SupplementaryGroups=docker +EnvironmentFile=/etc/frameflow/backup.env +WorkingDirectory=/srv/frameflow +ExecStart=/srv/frameflow/scripts/scheduled-maintenance.sh restore-latest +UMask=0077 +TimeoutStartSec=6h +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=6 +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=strict +ProtectControlGroups=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +ReadOnlyPaths=/mnt/encrypted-backups/frameflow +ReadWritePaths=/var/lib/frameflow/metrics /var/run/docker.sock diff --git a/deploy/systemd/frameflow-restore-rehearsal.timer b/deploy/systemd/frameflow-restore-rehearsal.timer new file mode 100644 index 0000000..f59fdaf --- /dev/null +++ b/deploy/systemd/frameflow-restore-rehearsal.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Run a FrameFlow isolated restore rehearsal every month + +[Timer] +OnCalendar=*-*-01 05:15:00 +RandomizedDelaySec=30m +AccuracySec=1m +Persistent=true +Unit=frameflow-restore-rehearsal.service + +[Install] +WantedBy=timers.target diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..9a57775 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,324 @@ +name: ${COMPOSE_PROJECT_NAME:-frameflow-prod} + +x-server-image: &server-image + image: frameflow-server:${FRAMEFLOW_VERSION:-latest} + build: + context: . + dockerfile: Dockerfile.server + +x-server-environment: &server-environment + NODE_ENV: production + API_HOST: 0.0.0.0 + API_PORT: 8787 + API_PUBLIC_ORIGIN: ${API_PUBLIC_ORIGIN:?Set API_PUBLIC_ORIGIN} + WEB_ORIGIN: ${WEB_ORIGIN:?Set WEB_ORIGIN} + LOG_LEVEL: ${LOG_LEVEL:-info} + METRICS_HOST: 0.0.0.0 + WORKER_METRICS_PORT: 9092 + WORKER_HEARTBEAT_KEY: ${WORKER_HEARTBEAT_KEY:-frameflow:worker:heartbeat} + WORKER_HEARTBEAT_INTERVAL_MS: ${WORKER_HEARTBEAT_INTERVAL_MS:-5000} + WORKER_HEARTBEAT_TTL_SECONDS: ${WORKER_HEARTBEAT_TTL_SECONDS:-20} + METRICS_TOKEN: ${METRICS_TOKEN:-} + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL} + REDIS_URL: ${REDIS_URL:?Set REDIS_URL} + JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?Set JWT_ACCESS_SECRET} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?Set CREDENTIAL_ENCRYPTION_KEY} + ACCESS_TOKEN_TTL: ${ACCESS_TOKEN_TTL:-15m} + REFRESH_TOKEN_TTL_DAYS: ${REFRESH_TOKEN_TTL_DAYS:-30} + EMAIL_VERIFICATION_REQUIRED: ${EMAIL_VERIFICATION_REQUIRED:-true} + EMAIL_VERIFICATION_TTL_HOURS: ${EMAIL_VERIFICATION_TTL_HOURS:-24} + PASSWORD_RESET_TTL_MINUTES: ${PASSWORD_RESET_TTL_MINUTES:-30} + RESEND_API_KEY: ${RESEND_API_KEY:?Set RESEND_API_KEY} + EMAIL_FROM: ${EMAIL_FROM:?Set EMAIL_FROM} + S3_ENDPOINT: http://minio:9000 + S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:?Set S3_PUBLIC_ENDPOINT} + S3_REGION: ${S3_REGION:-us-east-1} + S3_BUCKET: ${S3_BUCKET:-frameflow} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + S3_FORCE_PATH_STYLE: "true" + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_TEXT_MODEL: ${OPENAI_TEXT_MODEL:-gpt-5.6-sol} + OPENAI_IMAGE_MODEL: ${OPENAI_IMAGE_MODEL:-gpt-image-2} + OPENAI_TTS_MODEL: ${OPENAI_TTS_MODEL:-gpt-4o-mini-tts} + OPENAI_TTS_VOICE: ${OPENAI_TTS_VOICE:-alloy} + AGNES_API_KEY: ${AGNES_API_KEY:-} + AGNES_TEXT_MODEL: ${AGNES_TEXT_MODEL:-agnes-2.5-flash} + AGNES_IMAGE_MODEL: ${AGNES_IMAGE_MODEL:-agnes-image-2.1-flash} + AGNES_VIDEO_MODEL: ${AGNES_VIDEO_MODEL:-agnes-video-v2.0} + AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS: ${AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS:-900} + AGNES_VIDEO_POLL_INTERVAL_MS: ${AGNES_VIDEO_POLL_INTERVAL_MS:-4000} + GENERIC_TEXT_API_KEY: ${GENERIC_TEXT_API_KEY:-} + GENERIC_TEXT_BASE_URL: ${GENERIC_TEXT_BASE_URL:-} + GENERIC_TEXT_MODEL: ${GENERIC_TEXT_MODEL:-} + GENERIC_IMAGE_API_KEY: ${GENERIC_IMAGE_API_KEY:-} + GENERIC_IMAGE_BASE_URL: ${GENERIC_IMAGE_BASE_URL:-} + GENERIC_IMAGE_MODEL: ${GENERIC_IMAGE_MODEL:-} + GENERIC_VIDEO_API_KEY: ${GENERIC_VIDEO_API_KEY:-} + GENERIC_VIDEO_BASE_URL: ${GENERIC_VIDEO_BASE_URL:-} + GENERIC_VIDEO_MODEL: ${GENERIC_VIDEO_MODEL:-} + GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS: ${GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS:-900} + GENERIC_VIDEO_POLL_INTERVAL_MS: ${GENERIC_VIDEO_POLL_INTERVAL_MS:-4000} + REPLICATE_API_TOKEN: ${REPLICATE_API_TOKEN:-} + REPLICATE_IMAGE_VERSION: ${REPLICATE_IMAGE_VERSION:-} + REPLICATE_VIDEO_VERSION: ${REPLICATE_VIDEO_VERSION:-} + REPLICATE_LIPSYNC_VERSION: ${REPLICATE_LIPSYNC_VERSION:-} + REPLICATE_PREDICTION_DEADLINE_SECONDS: ${REPLICATE_PREDICTION_DEADLINE_SECONDS:-900} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID:-} + STRIPE_STUDIO_PRICE_ID: ${STRIPE_STUDIO_PRICE_ID:-} + STRIPE_PORTAL_CONFIGURATION_ID: ${STRIPE_PORTAL_CONFIGURATION_ID:-} + BILLING_SUCCESS_URL: ${BILLING_SUCCESS_URL:-} + BILLING_CANCEL_URL: ${BILLING_CANCEL_URL:-} + PUBLISHING_CONNECTOR_URL: ${PUBLISHING_CONNECTOR_URL:-} + PUBLISHING_CONNECTOR_SECRET: ${PUBLISHING_CONNECTOR_SECRET:-} + PUBLISHING_CONNECTOR_TIMEOUT_MS: ${PUBLISHING_CONNECTOR_TIMEOUT_MS:-30000} + FFMPEG_PATH: ffmpeg + FFPROBE_PATH: ffprobe + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-2} + MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-104857600} + PROVIDER_DOWNLOAD_TIMEOUT_MS: ${PROVIDER_DOWNLOAD_TIMEOUT_MS:-180000} + PROVIDER_DOWNLOAD_MAX_REDIRECTS: ${PROVIDER_DOWNLOAD_MAX_REDIRECTS:-3} + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-frameflow} + POSTGRES_USER: ${POSTGRES_USER:-frameflow} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + redis: + image: redis:7.4-alpine + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD:?Set REDIS_PASSWORD} + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$${REDIS_PASSWORD}\" ping | grep -q PONG"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + minio: + image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + MINIO_API_CORS_ALLOW_ORIGIN: ${WEB_ORIGIN:?Set WEB_ORIGIN} + MINIO_PROMETHEUS_AUTH_TYPE: public + volumes: + - minio_data:/data + ports: + - "${MINIO_CONSOLE_BIND:-127.0.0.1}:${MINIO_CONSOLE_PORT:-9001}:9001" + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 5s + timeout: 5s + retries: 20 + restart: unless-stopped + + minio-init: + image: minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 + depends_on: + minio: + condition: service_healthy + environment: + S3_BUCKET: ${S3_BUCKET:-frameflow} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set local http://minio:9000 "$${S3_ACCESS_KEY}" "$${S3_SECRET_KEY}" && + mc mb --ignore-existing "local/$${S3_BUCKET}" && + mc anonymous set none "local/$${S3_BUCKET}" + restart: "no" + + config-check: + <<: *server-image + environment: *server-environment + command: ["node", "server/dist/config-check.js"] + read_only: true + tmpfs: + - /tmp:size=16m + restart: "no" + + migrate: + <<: *server-image + environment: *server-environment + command: ["node", "server/dist/db/migrate.js"] + depends_on: + config-check: + condition: service_completed_successfully + postgres: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=128m + restart: "no" + + api: + <<: *server-image + environment: *server-environment + command: ["node", "server/dist/index.js"] + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully + read_only: true + tmpfs: + - /tmp:size=512m + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + start_period: 15s + retries: 10 + restart: unless-stopped + + worker: + <<: *server-image + environment: *server-environment + command: ["node", "server/dist/worker.js"] + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_healthy + minio-init: + condition: service_completed_successfully + read_only: true + tmpfs: + - /tmp:size=4g + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:9092/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + start_period: 15s + retries: 10 + restart: unless-stopped + + web: + image: frameflow-web:${FRAMEFLOW_VERSION:-latest} + build: + context: . + dockerfile: Dockerfile.web + args: + VITE_API_URL: /api/v1 + environment: + APP_HOST: ${APP_HOST:?Set APP_HOST} + MEDIA_HOST: ${MEDIA_HOST:?Set MEDIA_HOST} + MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-100m} + ports: + - "${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-8080}:80" + depends_on: + api: + condition: service_healthy + minio: + condition: service_healthy + restart: unless-stopped + + prometheus: + image: prom/prometheus:v3.5.0 + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --storage.tsdb.retention.time=${PROMETHEUS_RETENTION:-30d} + volumes: + - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./deploy/alerts.yml:/etc/prometheus/alerts.yml:ro + - prometheus_data:/prometheus + ports: + - "${PROMETHEUS_BIND:-127.0.0.1}:${PROMETHEUS_PORT:-9090}:9090" + depends_on: + api: + condition: service_healthy + worker: + condition: service_healthy + minio: + condition: service_healthy + node-exporter: + condition: service_healthy + alertmanager: + condition: service_healthy + restart: unless-stopped + + node-exporter: + image: quay.io/prometheus/node-exporter:v1.9.1 + command: + - --path.rootfs=/host + - --collector.textfile.directory=/textfile + pid: host + volumes: + - /:/host:ro + - ${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}:/textfile:ro + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:9100/metrics"] + interval: 15s + timeout: 5s + start_period: 5s + retries: 10 + restart: unless-stopped + + alertmanager: + image: prom/alertmanager:v0.28.1 + environment: + ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:?Set ALERTMANAGER_WEBHOOK_URL} + entrypoint: ["/bin/sh", "-ec"] + command: + - |- + case "$${ALERTMANAGER_WEBHOOK_URL}" in + https://*) ;; + *) echo "ALERTMANAGER_WEBHOOK_URL must use HTTPS" >&2; exit 1 ;; + esac + escaped_url=$$(printf '%s' "$${ALERTMANAGER_WEBHOOK_URL}" | sed 's/[\\&|]/\\&/g') + sed "s|__ALERTMANAGER_WEBHOOK_URL__|$${escaped_url}|g" /etc/alertmanager/alertmanager.yml.template > /tmp/alertmanager.yml + /bin/amtool check-config /tmp/alertmanager.yml + exec /bin/alertmanager \ + --config.file=/tmp/alertmanager.yml \ + --storage.path=/alertmanager \ + --enable-feature=receiver-name-in-metrics + volumes: + - ./deploy/alertmanager.yml.template:/etc/alertmanager/alertmanager.yml.template:ro + - alertmanager_data:/alertmanager + ports: + - "${ALERTMANAGER_BIND:-127.0.0.1}:${ALERTMANAGER_PORT:-9093}:9093" + read_only: true + tmpfs: + - /tmp:size=16m + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:9093/-/ready"] + interval: 10s + timeout: 5s + start_period: 5s + retries: 10 + restart: unless-stopped + +volumes: + postgres_data: + redis_data: + minio_data: + prometheus_data: + alertmanager_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..63fc37b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +name: frameflow + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: frameflow + POSTGRES_USER: frameflow + POSTGRES_PASSWORD: frameflow + ports: + - "54329:5432" + volumes: + - frameflow_postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U frameflow -d frameflow"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + redis: + image: redis:7.4-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: + - "63799:6379" + volumes: + - frameflow_redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: frameflow + MINIO_ROOT_PASSWORD: frameflow-local-secret + ports: + - "9000:9000" + - "9001:9001" + volumes: + - frameflow_minio:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 3s + timeout: 3s + retries: 20 + restart: unless-stopped + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: >- + /bin/sh -c " + mc alias set local http://minio:9000 frameflow frameflow-local-secret && + mc mb --ignore-existing local/frameflow && + mc anonymous set none local/frameflow + " + restart: "no" + +volumes: + frameflow_postgres: + frameflow_redis: + frameflow_minio: diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..f646a73 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,350 @@ +# Production deployment + +The production stack contains Nginx, the React web build, the Fastify API, a BullMQ worker, PostgreSQL, Redis, MinIO, Prometheus, and Alertmanager. API and storage services are not published directly. Nginx routes the application host to the SPA/API and the media host to MinIO. + +## Prerequisites + +- Docker Engine with Compose v2 +- Two DNS records pointing to the deployment host, such as `studio.example.com` and `media.example.com` +- An HTTPS reverse proxy or load balancer in front of port `8080` +- OpenAI and Replicate credentials for real generation jobs +- A Stripe account, recurring Price IDs, and webhook signing secret when subscription billing is enabled +- An HTTPS operator webhook that accepts Alertmanager webhook payloads +- An encrypted off-host destination for daily backups + +Both public hosts must be forwarded to the same Nginx port with the original `Host` header preserved. TLS is required because production refresh cookies are secure. `S3_PUBLIC_ENDPOINT` must exactly match the public media origin; providers and browsers cannot use the private `http://minio:9000` address. + +Production registration requires verified email by default. Configure `RESEND_API_KEY` and a verified `EMAIL_FROM` sender before startup; password-reset, verification, and workspace-invitation links use the first origin in `WEB_ORIGIN` as their public base URL. Workspace invitations remain valid when email delivery fails and the API returns a one-time manual link, but operators should treat a `FAILED` delivery result as an email-provider incident rather than asking the inviter to recreate the workspace. + +Subscription billing remains read-only when Stripe is not configured. To enable checkout, set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRO_PRICE_ID`, and `STRIPE_STUDIO_PRICE_ID`, then register `https://studio.example.com/api/v1/billing/webhooks/stripe` for `checkout.session.completed` and `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. Configure the Stripe Customer Portal before exposing subscription management; `STRIPE_PORTAL_CONFIGURATION_ID` is optional when the account default configuration is used. + +Before enabling billing, run the read-only Stripe resource smoke. It never creates a customer, session, charge, subscription, or event. Use `--require-live` for the production account: + +```bash +FRAMEFLOW_STRIPE_APP_ORIGIN=https://studio.example.com \ +STRIPE_SECRET_KEY='use-the-secret-store-value' \ +STRIPE_WEBHOOK_SECRET='use-the-secret-store-value' \ +STRIPE_PRO_PRICE_ID=price_replace_with_pro \ +STRIPE_STUDIO_PRICE_ID=price_replace_with_studio \ +STRIPE_PORTAL_CONFIGURATION_ID=bpc_replace_when_not_using_default \ + npm run smoke:stripe -- --require-live --json +``` + +The smoke retrieves the current account, expands both configured Products from their recurring Prices, validates active licensed recurring billing and live/test mode consistency, finds the explicit or default active Customer Portal configuration, and locates the exact enabled FrameFlow webhook with all four required events. The secret key and webhook signing secret are read only from the environment and are never printed. Stripe does not expose an endpoint's signing secret through its API, so this check can prove that `STRIPE_WEBHOOK_SECRET` is configured but cannot prove it belongs to the discovered endpoint. Complete one signed test subscription event through the FrameFlow webhook and confirm the workspace plan/credit update before accepting billing. + +## Publishing connector + +Direct delivery to Douyin, Kuaishou, Bilibili, and Xiaohongshu is intentionally isolated behind an operator-owned connector. The connector holds platform application credentials and translates the stable FrameFlow contract into each platform's OAuth, upload, polling, and token-refresh APIs. Without it, download delivery remains available and the publishing UI reports every external channel as not configured. + +Configure these values together: + +- `API_PUBLIC_ORIGIN`: the public HTTPS FrameFlow API origin used to build OAuth and status callback URLs. +- `CREDENTIAL_ENCRYPTION_KEY`: a production-only secret of at least 32 characters used to encrypt channel access tokens, refresh tokens, and OAuth PKCE verifiers at rest. +- `PUBLISHING_CONNECTOR_URL`: the connector's public HTTPS origin. +- `PUBLISHING_CONNECTOR_SECRET`: a shared secret of at least 24 characters. FrameFlow sends it as `Authorization: Bearer ` and also uses it to verify connector callbacks. +- `PUBLISHING_CONNECTOR_TIMEOUT_MS`: timeout for each connector request, from 1,000 to 120,000 milliseconds. + +Every connector request is JSON and includes `platform`, one of `DOUYIN`, `KUAISHOU`, `BILIBILI`, or `XIAOHONGSHU`. The connector must expose: + +- `POST /v1/oauth/authorize`: accepts `{ platform, state, callbackUrl }`; returns `{ authorizationUrl, codeVerifier? }`. The platform must return the supplied `state` to `callbackUrl`. +- `POST /v1/oauth/exchange`: accepts `{ platform, code, callbackUrl, codeVerifier? }`; returns `{ externalAccountId, displayName, avatarUrl?, accessToken, refreshToken?, tokenExpiresAt?, scopes?, metadata? }`. +- `POST /v1/oauth/revoke`: accepts `{ platform, externalAccountId, accessToken, refreshToken? }`; returns `{ revoked: boolean }`. +- `POST /v1/publish`: accepts `{ platform, deliveryId, sourceUrl, callbackUrl, account, destination }`; returns `{ status, externalId, publishedUrl?, credentials? }`. `status` is `PUBLISHING` for asynchronous processing or `PUBLISHED` only when the platform has confirmed publication. `sourceUrl` is a short-lived signed media URL and must not be persisted or logged. + +For asynchronous work, POST status events to the supplied FrameFlow `callbackUrl` with this body: + +```json +{ + "eventId": "provider-unique-event-id", + "platform": "DOUYIN", + "deliveryId": "00000000-0000-0000-0000-000000000000", + "externalId": "platform-video-id", + "status": "PUBLISHED", + "occurredAt": "2026-07-31T12:00:00.000Z", + "publishedUrl": "https://platform.example/video/123" +} +``` + +`status` may be `PUBLISHING`, `PUBLISHED`, or `FAILED`; include `errorMessage` for failures. Sign the exact raw JSON bytes with HMAC-SHA256 using `PUBLISHING_CONNECTOR_SECRET`, and send the lowercase hexadecimal digest as `x-frameflow-signature: sha256=`. FrameFlow deduplicates events by platform and event ID, rejects stale or mismatched events, and never allows a terminal delivery to regress. + +Before enabling a channel in production, bind and revoke a dedicated test account, publish an approved test render, wait for a signed terminal callback, open the returned platform URL, verify title/description/visibility/tags/schedule behavior, and confirm that refreshed credentials remain usable. Configuration-only readiness or a successful queue submission is not acceptance evidence. + +## Continuous integration + +`.github/workflows/ci.yml` provides two required pre-deployment checks. The verification job starts isolated PostgreSQL, Redis, and MinIO services, applies every migration, runs all web and server tests, creates a real backup of those services, restores it through the isolated rehearsal path, builds both applications, and audits production dependencies. The container job validates the production Compose model, builds the server and web images, confirms a synthetic safe production configuration is accepted, and confirms `.env.production.example` is rejected because it still contains placeholders. + +The synthetic values exist only inside the CI runner and are not deployment secrets. CI proves the repository is internally buildable and the safety gate works; it does not prove external provider credentials, public TLS/media routing, email delivery, billing webhooks, alert delivery, or cloud generation jobs. Complete the live checks below for every release candidate deployed to a real environment. + +## Production smoke test + +Use a dedicated, verified account with the `OWNER` or `ADMIN` role. The smoke runner accepts its password only through the environment, requires HTTPS outside localhost, and does not disable certificate validation. Its default mode never starts OpenAI or Replicate jobs. It performs these checks through the public application origin: + +- API liveness plus PostgreSQL, Redis, object-storage, and worker readiness +- authenticated service readiness including FFmpeg, AI configuration, and any persisted provider-verification state without starting a new provider request +- billing/usage read-model access +- project, episode, immutable script, asset, and shot-version persistence +- multipart object upload plus signed-download SHA-256 integrity +- a real Redis/BullMQ worker health job +- a 640x360 FFmpeg render, immutable source manifest, SRT/VTT sidecars, and signed video download +- render review approval, audit-log persistence, download delivery, redirect handling, and delivered-video integrity +- durable render/delivery notifications, user scoping, and persisted read state + +```bash +FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \ +FRAMEFLOW_SMOKE_EMAIL=production-smoke@example.com \ +FRAMEFLOW_SMOKE_PASSWORD='use-the-secret-store-value' \ +FRAMEFLOW_SMOKE_WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \ + npm run smoke:production -- --json +``` + +The workspace ID is optional when the account belongs to exactly one workspace. The default per-job timeout is 180 seconds and can be changed with `--timeout-seconds`. The test creates a uniquely marked project and archives it in a `finally` cleanup path on success or failure; use `--keep-project` only for diagnosis. Archiving preserves its database history and private media objects, so apply an operator-reviewed retention policy to old `[SMOKE]` projects rather than granting the test runner destructive storage permissions. + +For local-only verification, explicitly allow an HTTP loopback origin: + +```bash +FRAMEFLOW_SMOKE_BASE_URL=http://127.0.0.1:8787 \ +FRAMEFLOW_SMOKE_ALLOW_HTTP=true \ +FRAMEFLOW_SMOKE_EMAIL=studio@frameflow.local \ +FRAMEFLOW_SMOKE_PASSWORD='FrameFlow2026!' \ + npm run smoke:production -- --timeout-seconds 120 +``` + +A passing default report proves the local production path, storage, queue, render, review, and delivery adapters worked for that deployment. It never contacts an AI provider. AI readiness is configuration-only unless an owner or administrator previously completed the provider connection check; persisted connection status still does not replace the billable generation acceptance run. + +To validate the live provider path, add `--include-ai` and the exact billable-charge acknowledgement. Both are required; setting the environment acknowledgement alone never enables provider calls. Run this only in a workspace where test media and provider charges are acceptable: + +```bash +FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \ +FRAMEFLOW_SMOKE_EMAIL=production-smoke@example.com \ +FRAMEFLOW_SMOKE_PASSWORD='use-the-secret-store-value' \ +FRAMEFLOW_SMOKE_WORKSPACE_ID=00000000-0000-0000-0000-000000000000 \ +FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=I_ACCEPT_BILLABLE_AI_CHARGES \ +FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS=5 \ + npm run smoke:production -- --include-ai --json +``` + +The live preflight runs before project creation and requires all seven workflows (`script`, `storyboard`, `image-openai`, `image-replicate`, `tts`, `video`, and `lipsync`) to be `CONFIGURED`, plus at least 700 remaining workspace credits. It then creates one OpenAI character image, one Replicate consistency variant, an OpenAI script and storyboard, OpenAI speech, Replicate video and lipsync media, and an FFmpeg render. Every provider job uses `maxAttempts: 1`; the report verifies stored media, provider/model attribution, the final render, and one usage-ledger entry per provider job. The uniquely marked smoke project is archived in the same cleanup path used by default mode unless `--keep-project` is supplied. + +`FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS` accepts 1 to 30 seconds and defaults to 5. Some configured Replicate model versions require additional input fields. Supply only fields documented by those exact model versions as JSON objects through `FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON` and `FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON`; the runner rejects malformed JSON and arrays before login. Leave them unset when the selected versions need no extra parameters. Do not put provider tokens or other secrets in these JSON values or command-line arguments. + +Example Caddy routing in front of the stack: + +```caddyfile +studio.example.com { + reverse_proxy 127.0.0.1:8080 +} + +media.example.com { + reverse_proxy 127.0.0.1:8080 +} +``` + +The web client keeps one authenticated server-sent event stream open at `/api/v1/workspaces/:workspaceId/events`. The supplied API response disables proxy buffering and sends a heartbeat every 20 seconds. Preserve the `X-Accel-Buffering: no` response header, keep the upstream read timeout above 60 seconds, and do not enable response caching or compression that buffers `text/event-stream`. Notification history and read state are persisted in PostgreSQL, so reconnecting clients recover missed events through the regular notification endpoint; the existing task poller remains a degraded-mode fallback when streaming is unavailable. + +## First deployment + +1. Create the production environment file and replace every placeholder secret. + + ```bash + cp .env.production.example .env.production + openssl rand -hex 32 + ``` + +2. Create the host directory shared by the backup script and Node Exporter. If `FRAMEFLOW_BACKUP_METRICS_DIR` is changed in `.env.production`, use the same path here and in the backup scheduler environment. + + ```bash + sudo install -d -m 0755 -o "$(id -un)" -g "$(id -gn)" /var/lib/frameflow/metrics + ``` + +3. Validate interpolation, build the lightweight configuration gate, and run it before starting any stateful application service. + + ```bash + docker compose --env-file .env.production -f docker-compose.prod.yml config --quiet + docker compose --env-file .env.production -f docker-compose.prod.yml build config-check + docker compose --env-file .env.production -f docker-compose.prod.yml run --rm --no-deps config-check + ``` + + The gate rejects development defaults, placeholder credentials, identical JWT signing secrets, localhost or non-HTTPS public endpoints, unauthenticated database/Redis URLs, Replicate model versions without a token, and partially configured Stripe billing. It prints only a sanitized capability summary and never logs credential values. The migration service also depends on this check, so an unsafe configuration cannot migrate or start the API through the supplied Compose stack. + +4. Build and start the stack. Database migrations and bucket initialization run as one-shot dependencies before the API and worker start. + + ```bash + docker compose --env-file .env.production -f docker-compose.prod.yml up -d --build + docker compose --env-file .env.production -f docker-compose.prod.yml ps + ``` + +5. Verify public and internal health. + + ```bash + curl -fsS https://studio.example.com/health/live + curl -fsS https://studio.example.com/health/ready + docker compose --env-file .env.production -f docker-compose.prod.yml exec api node -e "fetch('http://127.0.0.1:8787/health/ready').then(r=>r.text()).then(console.log)" + docker compose --env-file .env.production -f docker-compose.prod.yml exec worker node -e "fetch('http://127.0.0.1:9092/health/ready').then(r=>r.text()).then(console.log)" + ``` + + API readiness includes PostgreSQL, Redis, object storage, and the Redis-backed worker heartbeat. A running API returns `503 not_ready` when the generation worker is absent or stale, so the web service is not promoted while production jobs cannot run. + +6. Create the first workspace owner through the registration API, then use that account on the login screen. + + ```bash + curl -fsS https://studio.example.com/api/v1/auth/register \ + -H 'content-type: application/json' \ + --data '{"email":"owner@example.com","password":"replace-with-a-long-password","displayName":"Studio Owner","workspaceName":"My Studio"}' + ``` + +Public registration should be disabled or protected at the edge after account provisioning when operating an invite-only deployment. + +## Media and model checks + +MinIO CORS is restricted to `WEB_ORIGIN` for browser requests. CORS does not make objects public; every object remains private and requires a short-lived signature. + +Before accepting production work, verify one job of every configured provider type: + +- script generation and storyboard generation +- character image generation using reference asset versions +- speech generation +- image-to-video generation +- lipsync generation +- episode render, immutable source-manifest capture, SRT/VTT subtitle download, and approved video delivery + +Workspace owners and administrators can run a non-generating connection check from the AI Services page. OpenAI verification retrieves every distinct configured text, image, and speech model through the Models API. Replicate verification reads the authenticated account endpoint, so it proves the token is accepted but does not prove that a configured model version can complete a prediction. Results are stored per workspace, audited, and automatically ignored after a credential or model configuration change. Provider response bodies and credentials are never stored. Complete the live billable workflow below before treating generation as accepted. + +Replicate must be able to fetch the public media origin over HTTPS. Provider output downloads are accepted only from public HTTPS addresses; every redirect is revalidated, streamed data is capped by `MAX_UPLOAD_BYTES`, and `PROVIDER_DOWNLOAD_TIMEOUT_MS` plus `PROVIDER_DOWNLOAD_MAX_REDIRECTS` bound stalled or redirecting responses. Do not mark provider validation complete when only queue creation succeeds; inspect the resulting media and usage ledger entry. + +`REPLICATE_PREDICTION_DEADLINE_SECONDS` controls both the local polling deadline and Replicate's `Cancel-After` deadline. It accepts 5 to 86,400 seconds and defaults to 900. Set it high enough for the selected model versions, but keep it bounded so a worker timeout does not leave a remote prediction running and accruing charges. + +## Monitoring + +Prometheus listens on `127.0.0.1:9090` by default and scrapes API, worker, host, and MinIO metrics over the private Compose network. MinIO's metrics endpoint is unauthenticated only inside that network; Nginx returns `404` for both v2 and v3 metrics paths on the public media host. Alertmanager listens on `127.0.0.1:9093` and sends grouped firing and resolved notifications to `ALERTMANAGER_WEBHOOK_URL`. The URL is rendered into a temporary in-container config and must use HTTPS. Keep `METRICS_TOKEN` empty with the supplied scrape configuration. If metrics are exposed outside the Compose network, configure a token and update Prometheus authorization at the same time. + +Useful checks: + +```bash +curl -fsS http://127.0.0.1:9090/-/ready +curl -fsS http://127.0.0.1:9093/-/ready +curl -fsS 'http://127.0.0.1:9090/api/v1/targets' +curl -fsS 'http://127.0.0.1:9090/api/v1/rules' +curl -fsS 'http://127.0.0.1:9090/api/v1/alerts' +curl -fsS 'http://127.0.0.1:9090/api/v1/alertmanagers' +docker compose --env-file .env.production -f docker-compose.prod.yml logs --since=30m api worker node-exporter prometheus alertmanager +``` + +The supplied `deploy/alerts.yml` rules cover API/worker/exporter disappearance, stale worker heartbeat, dependency readiness, host and PostgreSQL-volume disk pressure, MinIO usable capacity, backup age, restore-rehearsal age, API error rate and latency, generation failure rate, and queue delay. Prometheus evaluates these rules and routes alerts to the bundled Alertmanager. The exact `FrameFlowDeliveryTest` alert uses a dedicated one-second group wait and five-second group interval so production acceptance does not change the timing of operational alerts. + +Run the controlled delivery smoke before launch. The exact acknowledgement is required because this command sends real firing and resolved notifications to the configured operator webhook: + +```bash +FRAMEFLOW_ALERTMANAGER_URL=http://127.0.0.1:9093 \ +FRAMEFLOW_ALERT_TEST_ACK=I_ACCEPT_TEST_ALERT_NOTIFICATIONS \ + npm run smoke:alertmanager -- --json +``` + +The production Alertmanager enables its supported `receiver-name-in-metrics` feature so the command can scope notification counters to `frameflow-operator-webhook`. The command records those counters, posts a uniquely labeled firing alert, waits for the alert to become active and for a successful webhook attempt, resolves the same alert, then waits for both the inactive state and a second successful webhook attempt. It fails when Alertmanager reports a failed notification and always attempts to resolve a firing test alert after an intermediate error. A passing result proves that the webhook returned success for both payloads. Use the returned `testId` to confirm the receiver performed its own downstream processing; Alertmanager cannot prove work performed after the receiver acknowledged the HTTP request. + +## Backups + +The backup script creates a PostgreSQL custom-format dump plus Redis and MinIO volume archives, then writes checksums. Before publishing success metrics it runs the read-only backup verifier, which checks the required file set, manifest shape, exact checksum inventory, PostgreSQL custom-format signature, readable gzip archives, and unsafe absolute or parent-directory archive paths. Only after every check succeeds does it atomically publish backup timestamp, duration, and size metrics for Node Exporter. Run it from the repository root or a daily scheduler with an explicit off-host destination. A failed run leaves the previous success timestamp unchanged so the stale-backup alert can fire. + +```bash +FRAMEFLOW_ENV_FILE=/srv/frameflow/.env.production \ +FRAMEFLOW_BACKUP_METRICS_DIR=/var/lib/frameflow/metrics \ + /srv/frameflow/scripts/backup.sh /mnt/encrypted-backups/frameflow +``` + +After the first successful backup, verify the metric through Prometheus: + +```bash +curl -fsS --get http://127.0.0.1:9090/api/v1/query \ + --data-urlencode 'query=frameflow_backup_last_success_timestamp_seconds' +``` + +Copy completed backup directories off the application host. A local Docker volume is not a disaster-recovery backup. Test checksum validation and restore into an isolated Compose project on a regular schedule. + +### Scheduled backup and restore rehearsal + +The supplied systemd units assume a system Docker daemon, an application checkout at `/srv/frameflow`, a `frameflow` service account with supplementary membership in the `docker` group, an encrypted mount at `/mnt/encrypted-backups`, and the standard metrics directory. Change every matching path in both service units when the host layout differs; changing only the environment file is insufficient because the systemd filesystem allowlist and mount assertion are intentionally explicit. + +Create the service account according to the host's account-management policy, mount the encrypted off-host filesystem through `/etc/fstab` or a dedicated mount unit, then install the configuration: + +```bash +sudo install -d -m 0750 -o root -g frameflow /etc/frameflow +sudo install -d -m 0750 -o frameflow -g frameflow /var/lib/frameflow/metrics +sudo install -d -m 0700 -o frameflow -g frameflow /mnt/encrypted-backups/frameflow +sudo chown root:frameflow /srv/frameflow/.env.production +sudo chmod 0640 /srv/frameflow/.env.production +sudo install -m 0640 -o root -g frameflow \ + deploy/systemd/frameflow-backup.env.example /etc/frameflow/backup.env +sudo install -m 0644 deploy/systemd/*.service deploy/systemd/*.timer /etc/systemd/system/ +sudoedit /etc/frameflow/backup.env +sudo systemd-analyze verify \ + /etc/systemd/system/frameflow-backup.service \ + /etc/systemd/system/frameflow-backup.timer \ + /etc/systemd/system/frameflow-restore-rehearsal.service \ + /etc/systemd/system/frameflow-restore-rehearsal.timer +sudo systemctl daemon-reload +sudo systemctl enable --now frameflow-backup.timer frameflow-restore-rehearsal.timer +``` + +`scheduled-maintenance.sh` resolves both configured paths, rejects relative paths, root, whitespace, symbolic links, destinations outside the mount, and inactive mount points. This prevents an unavailable encrypted mount from silently redirecting backups onto the application host. The monthly restore unit requires the daily backup service and waits for a fresh successful backup before selecting the newest timestamped directory. It verifies that directory once before the restore script verifies it again immediately before any Docker call. + +Run both units manually during first deployment and inspect their logs and timers: + +```bash +sudo systemctl start frameflow-backup.service +sudo systemctl start frameflow-restore-rehearsal.service +sudo systemctl status frameflow-backup.service frameflow-restore-rehearsal.service +sudo systemctl list-timers frameflow-backup.timer frameflow-restore-rehearsal.timer +journalctl -u frameflow-backup.service -u frameflow-restore-rehearsal.service --since today +``` + +The units intentionally do not delete old backups. Apply an operator-reviewed retention or immutable-storage lifecycle policy at the off-host destination, and keep at least one verified recovery point outside that policy's deletion window. + +Re-run verification after every off-host transfer and before any restore: + +```bash +./scripts/verify-backup.sh /mnt/encrypted-backups/frameflow/frameflow-20260731T040000Z +``` + +Verification is read-only and does not connect to or modify the running stack. Do not proceed when it reports a checksum, manifest, dump-format, or archive-path failure. + +At least monthly, restore a recently transferred backup into the isolated rehearsal environment: + +```bash +FRAMEFLOW_RESTORE_METRICS_DIR=/var/lib/frameflow/metrics \ + npm run restore:rehearse -- \ + /mnt/encrypted-backups/frameflow/frameflow-20260731T040000Z +``` + +The rehearsal verifies the backup before making any Docker call. It creates a randomly named internal network, three new empty volumes, and temporary PostgreSQL, Redis, and MinIO containers without publishing host ports. Every resource carries both a restore-rehearsal marker and a run-specific label. Cleanup rechecks both labels and refuses to remove a resource if either label differs, so the script never removes the live Compose project or an unrelated similarly named resource. + +PostgreSQL validation restores the custom-format dump with `--exit-on-error`, checks migration history and core tables, and queries representative business tables. Redis validation loads the restored persistence, checks `PING`, `DBSIZE`, loading state, and AOF health. MinIO validation starts the production-pinned server image and uses the pinned `mc` client to check administrative readiness and list restored buckets and objects. On success, the script cleans the temporary resources and atomically publishes timestamp, duration, PostgreSQL table-count, Redis key-count, and MinIO object-count metrics for Node Exporter. + +Failed rehearsals are cleaned automatically. For incident diagnosis only, add `--keep-on-failure`; the command prints the exact retained resource names and label values. Inspect those labels before manually removing anything. The `FrameFlowRestoreRehearsalStale` alert fires when no successful rehearsal metric exists or the last success is older than 35 days. + +Verify both disaster-recovery freshness metrics through Prometheus: + +```bash +curl -fsS --get http://127.0.0.1:9090/api/v1/query \ + --data-urlencode 'query={__name__=~"frameflow_(backup|restore_rehearsal)_last_success_timestamp_seconds"}' +``` + +For recovery, stop application writers first: + +```bash +docker compose --env-file .env.production -f docker-compose.prod.yml stop web api worker +``` + +Restore `postgres.dump` with `pg_restore` and unpack the Redis/MinIO archives only into newly created, empty recovery volumes. Restoring volume archives over live data can leave stale objects and is not supported. Start dependencies, run the `migrate` service, then start API, worker, and web and complete the media/model checks above. + +## Upgrades + +Use an immutable `FRAMEFLOW_VERSION`, build the new images, take a backup, and then recreate services. The migration job completes before new API and worker containers become healthy. + +```bash +./scripts/backup.sh /mnt/encrypted-backups/frameflow +docker compose --env-file .env.production -f docker-compose.prod.yml build +docker compose --env-file .env.production -f docker-compose.prod.yml up -d +docker compose --env-file .env.production -f docker-compose.prod.yml ps +``` + +Rollback application images only after confirming that the new migration remains backward compatible. Restore the database from backup when a migration is not reversible. diff --git a/index.html b/index.html new file mode 100644 index 0000000..12e7cca --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + + + FrameFlow AI 漫剧工场 + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cfb4d66 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6502 @@ +{ + "name": "frameflow-ai-comic-studio", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frameflow-ai-comic-studio", + "version": "0.1.0", + "workspaces": [ + "server" + ], + "dependencies": { + "@vitejs/plugin-react": "latest", + "concurrently": "^9.2.1", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "typescript": "latest", + "vite": "latest" + }, + "devDependencies": { + "vitest": "^3.2.4" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.23.tgz", + "integrity": "sha512-Hv4VX5+IdDYmAbOdiwZguz8fLh3WnE4VtyJwVNEHLulA+OYy7Z5L0ETGUlX2T4g8DLO8XxCV8CyWrtVL4uwqkg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1098.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1098.0.tgz", + "integrity": "sha512-6Z53QG2jdujsCt3eHO2cjCpdfvz2Pgs+hP/NINuGONZUJmdrHLLDvWM9F114Yq0NPIGtk+yA/GCjG3XiUhw+gA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.23", + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/credential-provider-node": "^3.972.75", + "@aws-sdk/middleware-sdk-s3": "^3.972.69", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.3.tgz", + "integrity": "sha512-6YZTpF5Zzl0KdSrztM0l6ErKdnXrnnodIDYfayHE9SFfZU8Ubxls7H2XnfWT121jgwiG+WP1z5kyfVDsH5V4qA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.64.tgz", + "integrity": "sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.66.tgz", + "integrity": "sha512-BrZxoXScBZ+nTOYy388zHkz1n5cS8C+uGFMozD4w9OKEWMq39Cu/9l/k385Hb54dtv0VIeh12f8h67o3xDNH9g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.9.tgz", + "integrity": "sha512-d/mMvS+sQjSWNA6+JCldK1YB/jQmtoWM8Izj10mrOZdRCeWNVb8IYQuuyHHKKKXcpGo5Pd0LIK6onHbLvlhuVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/credential-provider-env": "^3.972.64", + "@aws-sdk/credential-provider-http": "^3.972.66", + "@aws-sdk/credential-provider-login": "^3.972.71", + "@aws-sdk/credential-provider-process": "^3.972.64", + "@aws-sdk/credential-provider-sso": "^3.973.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.70", + "@aws-sdk/nested-clients": "^3.997.38", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.71.tgz", + "integrity": "sha512-l93922lXnTs5RjM3QrMCXSmXiEgLFLaQ0Lco9F3tJ08o0mzGdAj6m2nAXDuTMHoUuL0u7RKmrhm+Z8/7GCilyg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/nested-clients": "^3.997.38", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.75.tgz", + "integrity": "sha512-sdFhR4E3HWZefGL6OsBhxqXdqtFvxGOh8VLfiu9yttPgVNcpEozEDPmrTYmXA3CIf6npUETVLveQPGQ7GBzVhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.64", + "@aws-sdk/credential-provider-http": "^3.972.66", + "@aws-sdk/credential-provider-ini": "^3.973.9", + "@aws-sdk/credential-provider-process": "^3.972.64", + "@aws-sdk/credential-provider-sso": "^3.973.8", + "@aws-sdk/credential-provider-web-identity": "^3.972.70", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.64.tgz", + "integrity": "sha512-Q5noG5vfFo++bUaLGcjj8OHX5tmwa1O/5nDVMeaSSZgjzgvWbW6tgu/gSa+aENwcJXjoXDQh+8TRZR2zHds/aw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.8.tgz", + "integrity": "sha512-37okeZyRdVkGrU6nyKBPGqQvyl/7CEiMpUVOb0+BCNCIR0VAuBxNgjMb0mnsR1EXm8dqQFfQZOJ5YW/0sBfa1w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/nested-clients": "^3.997.38", + "@aws-sdk/token-providers": "3.1098.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.70.tgz", + "integrity": "sha512-Ps/f9USafScb6CSQTRPNMzdKL5x5XRwDIRgFvnuFWfClGZe7/fI7iCFqXdxKjc9LBxP28E7iUgNloGgfgw406w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/nested-clients": "^3.997.38", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.69.tgz", + "integrity": "sha512-ziSQA8AkB+u4K/5t/SStFbeoiNTG04reUeddCLNCAgmdRJGQ3+MJcpZOXXLdD6uyfCqSK1/R6004gRdXvHbYQA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.38.tgz", + "integrity": "sha512-jQDs+nxnmgo4+WPOmY373HIC8jx4KyYYMYDI74FWrzX3Ba9fYsV0dSA+7PNYNkhRbbRCRLre83tQ31F1ACCHkA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1098.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1098.0.tgz", + "integrity": "sha512-1FQp9O22rWMGBhLZI/fP14kVoxsylk4b8yHh3n78pbTvo1+NUEoe5gG4tFLTz+p6UpCna5qqjp28VlSJO3v8fA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1098.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1098.0.tgz", + "integrity": "sha512-6cFAviffeqYGjrXn4FqGbWcTxB3nbpBpQXUm6MvnrWZThJaVBTHHnespHgSUh4yVLfzhipSh9H/XjzkHQd9P4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.3", + "@aws-sdk/nested-clients": "^3.997.38", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.hirok.io", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@fastify/cookie": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz", + "integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@fastify/cors": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.0.tgz", + "integrity": "sha512-ggQGua+xHv1MvePbPr0v//xLYEsCXbWspquXCJS9Ot5YoRXq8J8ZWzHnxDBVnbtXosvistXo6LtNzOJswf64Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/helmet": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.0.tgz", + "integrity": "sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "helmet": "^8.0.0" + } + }, + "node_modules/@fastify/jwt": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.1.tgz", + "integrity": "sha512-8FHalB082a6HFj+neP9TdJZrUa65Aq6mtozVjd3vH3Yy7hmjRJUDKLPdxYRYF9S077JeN9RgimsautqnijbDlQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.2.0", + "@lukeed/ms": "^2.0.2", + "fast-jwt": "^6.2.4", + "fastify-plugin": "^6.0.0", + "steed": "^1.1.3" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/multipart": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz", + "integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@fastify/deepmerge": "^3.0.0", + "@fastify/error": "^4.0.0", + "fastify-plugin": "^5.0.0", + "secure-json-parse": "^4.0.0" + } + }, + "node_modules/@fastify/multipart/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/rate-limit/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/sensible": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@fastify/sensible/-/sensible-6.0.4.tgz", + "integrity": "sha512-1vxcCUlPMew6WroK8fq+LVOwbsLtX+lmuRuqpcp6eYqu6vmkLwbKTdBWAZwbeaSgCfW4tzUpTIHLLvTiQQ1BwQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "dequal": "^2.0.3", + "fastify-plugin": "^5.0.0", + "forwarded": "^0.2.0", + "http-errors": "^2.0.0", + "type-is": "^2.0.1", + "vary": "^1.1.2" + } + }, + "node_modules/@fastify/sensible/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@frameflow/server": { + "resolved": "server", + "link": true + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.1.tgz", + "integrity": "sha512-KjZdi8Q1wh89gsVmghvbrMgWl6ZWmRmHV6wjB7/g4Zf0dyO+hH3neZUtuDNPO00qq5YE5RITVWvrIZKRaAmzGQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/core": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", + "integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", + "license": "MIT" + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.81.3", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.81.3.tgz", + "integrity": "sha512-Q7uEH2G92rVjX3Yl2qw3+6VEO15uxehC6DfeTHQBQTehddcRDSDuquD9zuvDjxiCffXsPUaBwrRlvlbVcgKs7g==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.11.1", + "msgpackr": "2.0.5", + "node-abort-controller": "3.1.1", + "semver": "7.8.5", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-jwt": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.0.tgz", + "integrity": "sha512-qFVn4V6rdsqUVOr6WoSF8rt569SdWvORlPKhT4fh3vMeMN6pmkGdWcSfKdLM/G9B2KZp/9E/EOgsYbJUaaT2rQ==", + "license": "Apache-2.0", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "asn1.js": "^5.4.1", + "ecdsa-sig-formatter": "^1.0.11", + "mnemonist": "^0.40.0", + "safe-regex2": "^5.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastfall": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", + "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", + "license": "MIT", + "dependencies": { + "reusify": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify-raw-body": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/fastify-raw-body/-/fastify-raw-body-5.0.0.tgz", + "integrity": "sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==", + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "raw-body": "^3.0.0", + "secure-json-parse": "^2.4.0" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "url": "https://github.com/Eomm/fastify-raw-body?sponsor=1" + } + }, + "node_modules/fastify-raw-body/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify-raw-body/node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, + "node_modules/fastparallel": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", + "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4", + "xtend": "^4.0.2" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fastseries": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", + "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/mnemonist": { + "version": "0.40.4", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz", + "integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openai": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.2.0.tgz", + "integrity": "sha512-/P0jZP2WDSwSuiilTTcU4mkepzdSAiRrnTK2e9pk9KOzBoE3L9aHz2oIyIXqGbqnnMsGC0Izr223MOUhG2JuvQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/steed": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", + "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", + "license": "MIT", + "dependencies": { + "fastfall": "^1.5.0", + "fastparallel": "^2.2.0", + "fastq": "^1.3.0", + "fastseries": "^1.7.0", + "reusify": "^1.0.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/stripe": { + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "server": { + "name": "@frameflow/server", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.850.0", + "@aws-sdk/s3-request-presigner": "^3.850.0", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.1.0", + "@fastify/helmet": "^13.0.1", + "@fastify/jwt": "^10.0.0", + "@fastify/multipart": "^9.2.1", + "@fastify/rate-limit": "^10.3.0", + "@fastify/sensible": "^6.0.3", + "argon2": "^0.44.0", + "bullmq": "^5.58.0", + "dotenv": "^17.2.1", + "drizzle-orm": "^0.45.2", + "fastify": "^5.5.0", + "fastify-raw-body": "^5.0.0", + "ioredis": "^5.7.0", + "ipaddr.js": "^2.4.0", + "openai": "^7.2.0", + "pino": "^10.3.1", + "postgres": "^3.4.7", + "prom-client": "^15.1.3", + "stripe": "^22.4.0", + "zod": "^4.0.17" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "drizzle-kit": "^0.31.5", + "tsx": "^4.20.4", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } + }, + "server/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "server/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9b00673 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "frameflow-ai-comic-studio", + "private": true, + "version": "0.1.0", + "type": "module", + "workspaces": [ + "server" + ], + "scripts": { + "dev": "concurrently -n WEB,API,WORKER -c cyan,green,yellow \"npm:dev:web\" \"npm:dev:api\" \"npm:dev:worker\"", + "dev:web": "vite", + "dev:api": "npm run dev --workspace @frameflow/server", + "dev:worker": "npm run worker --workspace @frameflow/server", + "infra:up": "docker compose up -d postgres redis minio minio-init", + "infra:down": "docker compose down", + "backup:verify": "./scripts/verify-backup.sh", + "restore:rehearse": "./scripts/rehearse-restore.sh", + "smoke:production": "node scripts/smoke-test.mjs", + "smoke:alertmanager": "node scripts/alertmanager-smoke.mjs", + "smoke:stripe": "node scripts/stripe-smoke.mjs", + "db:generate": "npm run db:generate --workspace @frameflow/server", + "db:migrate": "npm run db:migrate --workspace @frameflow/server", + "db:seed": "npm run db:seed --workspace @frameflow/server", + "test": "npm run test:web && npm run test:server", + "test:web": "vitest run --config vitest.config.js", + "test:web:watch": "vitest --config vitest.config.js", + "test:server": "npm run test --workspace @frameflow/server", + "build": "vite build", + "build:server": "npm run build --workspace @frameflow/server", + "build:all": "npm run build && npm run build:server", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "vite": "latest", + "typescript": "latest", + "react": "latest", + "react-dom": "latest", + "lucide-react": "latest", + "concurrently": "^9.2.1" + }, + "devDependencies": { + "vitest": "^3.2.4" + } +} diff --git a/public/assets/character-linyao.jpg b/public/assets/character-linyao.jpg new file mode 100644 index 0000000..e0cc997 Binary files /dev/null and b/public/assets/character-linyao.jpg differ diff --git a/public/assets/character-lucheng.jpg b/public/assets/character-lucheng.jpg new file mode 100644 index 0000000..412f019 Binary files /dev/null and b/public/assets/character-lucheng.jpg differ diff --git a/public/assets/character-suyue.jpg b/public/assets/character-suyue.jpg new file mode 100644 index 0000000..bf54e5d Binary files /dev/null and b/public/assets/character-suyue.jpg differ diff --git a/public/assets/scene-city.jpg b/public/assets/scene-city.jpg new file mode 100644 index 0000000..be18077 Binary files /dev/null and b/public/assets/scene-city.jpg differ diff --git a/public/assets/scene-rooftop.jpg b/public/assets/scene-rooftop.jpg new file mode 100644 index 0000000..3f397e7 Binary files /dev/null and b/public/assets/scene-rooftop.jpg differ diff --git a/public/assets/scene-studio.jpg b/public/assets/scene-studio.jpg new file mode 100644 index 0000000..f5a4278 Binary files /dev/null and b/public/assets/scene-studio.jpg differ diff --git a/scripts/alertmanager-smoke.mjs b/scripts/alertmanager-smoke.mjs new file mode 100755 index 0000000..0842fc1 --- /dev/null +++ b/scripts/alertmanager-smoke.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node + +import { randomUUID } from 'node:crypto' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptPath = fileURLToPath(import.meta.url) +const acknowledgement = 'I_ACCEPT_TEST_ALERT_NOTIFICATIONS' +const defaultReceiver = 'frameflow-operator-webhook' + +export class AlertmanagerSmokeError extends Error { + constructor(message, options = {}) { + super(message, options) + this.name = 'AlertmanagerSmokeError' + } +} + +function required(value, name) { + if (typeof value !== 'string' || value.trim() === '') throw new AlertmanagerSmokeError(`${name} is required`) + return value.trim() +} + +function integerValue(value, name, minimum, maximum) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new AlertmanagerSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`) + } + return parsed +} + +export function resolveAlertmanagerUrl(value) { + let url + try { + url = new URL(required(value, 'FRAMEFLOW_ALERTMANAGER_URL')) + } catch (error) { + if (error instanceof AlertmanagerSmokeError) throw error + throw new AlertmanagerSmokeError('FRAMEFLOW_ALERTMANAGER_URL must be a valid URL') + } + if (url.username || url.password || url.search || url.hash) { + throw new AlertmanagerSmokeError('Alertmanager URL cannot contain credentials, a query, or a fragment') + } + if (!['http:', 'https:'].includes(url.protocol)) { + throw new AlertmanagerSmokeError('Alertmanager URL must use HTTP or HTTPS') + } + const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + if (url.protocol === 'http:' && !localHostnames.has(url.hostname)) { + throw new AlertmanagerSmokeError('HTTP is allowed only for a loopback Alertmanager URL') + } + if (url.pathname !== '/' && url.pathname !== '') { + throw new AlertmanagerSmokeError('Alertmanager URL must not contain a path') + } + return url.origin +} + +export function usage() { + return `Usage: npm run smoke:alertmanager -- [options] + +Sends a real FrameFlowDeliveryTest alert and resolution through Alertmanager, +then verifies successful webhook notification counters for both events. + +Required environment: + FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement} + +Optional environment: + FRAMEFLOW_ALERTMANAGER_URL=http://127.0.0.1:9093 + FRAMEFLOW_ALERT_TEST_RECEIVER=${defaultReceiver} + FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS=60 + +Options: + --url Alertmanager origin; HTTP is loopback-only + --receiver Receiver label used by notification metrics + --timeout-seconds <15-300> + --json + --help + +This command causes real firing and resolved webhook notifications. A passing +report proves that the configured webhook returned success to Alertmanager; it +does not prove downstream processing after that acknowledgement. +` +} + +export function parseAlertmanagerSmokeOptions(argv = process.argv.slice(2), environment = process.env) { + const values = {} + const flags = new Set() + const valueOptions = new Set(['--url', '--receiver', '--timeout-seconds']) + const flagOptions = new Set(['--json', '--help']) + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (valueOptions.has(argument)) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new AlertmanagerSmokeError(`${argument} requires a value`) + values[argument] = value + index += 1 + } else if (flagOptions.has(argument)) { + flags.add(argument) + } else { + throw new AlertmanagerSmokeError(`Unknown option: ${argument}`) + } + } + if (flags.has('--help')) return { help: true } + if (environment.FRAMEFLOW_ALERT_TEST_ACK !== acknowledgement) { + throw new AlertmanagerSmokeError(`Alert smoke requires FRAMEFLOW_ALERT_TEST_ACK=${acknowledgement}`) + } + const receiver = required(values['--receiver'] ?? environment.FRAMEFLOW_ALERT_TEST_RECEIVER ?? defaultReceiver, 'receiver') + if (!/^[a-zA-Z0-9_.-]{1,100}$/.test(receiver)) { + throw new AlertmanagerSmokeError('receiver must contain only letters, digits, dot, underscore, or hyphen') + } + const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_ALERT_TEST_TIMEOUT_SECONDS ?? '60', 'timeout-seconds', 15, 300) + return { + alertmanagerUrl: resolveAlertmanagerUrl(values['--url'] ?? environment.FRAMEFLOW_ALERTMANAGER_URL ?? 'http://127.0.0.1:9093'), + receiver, + timeoutMs: timeoutSeconds * 1000, + requestTimeoutMs: Math.min(10_000, timeoutSeconds * 1000), + pollIntervalMs: 500, + json: flags.has('--json'), + } +} + +function unescapePrometheusLabel(value) { + return value.replace(/\\([\\"n])/g, (_match, escaped) => escaped === 'n' ? '\n' : escaped) +} + +function parsePrometheusLabels(value) { + const labels = {} + const matcher = /([a-zA-Z_][a-zA-Z0-9_]*)="((?:\\.|[^"\\])*)"/g + for (const match of value.matchAll(matcher)) labels[match[1]] = unescapePrometheusLabel(match[2]) + return labels +} + +export function notificationCounters(metricsText, receiver) { + const counters = { total: 0, failed: 0 } + for (const line of String(metricsText).split('\n')) { + const match = line.match(/^(alertmanager_notifications(?:_failed)?_total)\{([^}]*)\}\s+([^\s]+)$/) + if (!match) continue + const labels = parsePrometheusLabels(match[2]) + if (labels.integration !== 'webhook' || labels.receiver_name !== receiver) continue + const value = Number(match[3]) + if (!Number.isFinite(value)) continue + if (match[1] === 'alertmanager_notifications_failed_total') counters.failed += value + else counters.total += value + } + return counters +} + +function safeResponseText(value) { + return String(value ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 500) +} + +async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${timeoutMs}ms`)), timeoutMs) + try { + return await fetchImpl(url, { ...options, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +async function expectOk(fetchImpl, url, options, timeoutMs) { + const response = await fetchWithTimeout(fetchImpl, url, options, timeoutMs) + if (!response.ok) { + throw new AlertmanagerSmokeError(`${options.method ?? 'GET'} ${new URL(url).pathname} failed (${response.status}): ${safeResponseText(await response.text())}`) + } + return response +} + +async function postAlert(fetchImpl, options, alert) { + await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify([alert]), + }, options.requestTimeoutMs) +} + +async function listTestAlerts(fetchImpl, options, testId) { + const matcher = encodeURIComponent(`test_id="${testId}"`) + const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/api/v2/alerts?active=true&silenced=true&inhibited=true&unprocessed=true&filter=${matcher}`, {}, options.requestTimeoutMs) + const body = await response.json() + if (!Array.isArray(body)) throw new AlertmanagerSmokeError('Alertmanager returned an invalid alert list') + return body.filter((alert) => alert?.labels?.test_id === testId) +} + +async function readNotificationCounters(fetchImpl, options) { + const response = await expectOk(fetchImpl, `${options.alertmanagerUrl}/metrics`, {}, options.requestTimeoutMs) + return notificationCounters(await response.text(), options.receiver) +} + +async function waitUntil(check, description, options, dependencies) { + const deadline = dependencies.now() + options.timeoutMs + let lastError = null + do { + try { + const result = await check() + if (result?.fatal) throw result.error + if (result?.done) return result.value + } catch (error) { + lastError = error + } + if (dependencies.now() >= deadline) break + await dependencies.sleep(Math.min(options.pollIntervalMs, Math.max(1, deadline - dependencies.now()))) + } while (dependencies.now() <= deadline) + const suffix = lastError instanceof Error ? `: ${lastError.message}` : '' + throw new AlertmanagerSmokeError(`Timed out waiting for ${description}${suffix}`) +} + +async function waitForAlertState(fetchImpl, options, testId, expectedActive, dependencies) { + return waitUntil(async () => { + const alerts = await listTestAlerts(fetchImpl, options, testId) + const active = alerts.some((alert) => Date.parse(alert.endsAt) > dependencies.now()) + return active === expectedActive ? { done: true, value: alerts } : { done: false } + }, expectedActive ? 'the test alert to become active' : 'the test alert to resolve', options, dependencies) +} + +async function waitForSuccessfulNotification(fetchImpl, options, baseline, phase, dependencies) { + return waitUntil(async () => { + const counters = await readNotificationCounters(fetchImpl, options) + if (counters.failed > baseline.failed) { + return { + fatal: true, + error: new AlertmanagerSmokeError(`${phase} webhook notification failed according to Alertmanager metrics`), + } + } + return counters.total > baseline.total ? { done: true, value: counters } : { done: false } + }, `a successful ${phase} webhook notification`, options, dependencies) +} + +export async function runAlertmanagerSmoke(options, dependencies = {}) { + const fetchImpl = dependencies.fetchImpl ?? fetch + const now = dependencies.now ?? Date.now + const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))) + const createId = dependencies.createId ?? randomUUID + const runtime = { now, sleep } + const testId = createId() + const startsAt = new Date(now()).toISOString() + const firingAlert = { + labels: { + alertname: 'FrameFlowDeliveryTest', + severity: 'info', + service: 'frameflow', + test_id: testId, + }, + annotations: { + summary: 'FrameFlow controlled Alertmanager delivery test', + description: `Controlled firing notification for deployment acceptance test ${testId}.`, + }, + startsAt, + endsAt: new Date(now() + 15 * 60_000).toISOString(), + generatorURL: `${options.alertmanagerUrl}/#/alerts`, + } + const baseline = await readNotificationCounters(fetchImpl, options) + let firingPosted = false + let resolutionPosted = false + + try { + await postAlert(fetchImpl, options, firingAlert) + firingPosted = true + await waitForAlertState(fetchImpl, options, testId, true, runtime) + const afterFiring = await waitForSuccessfulNotification(fetchImpl, options, baseline, 'firing', runtime) + + const resolvedAt = new Date(now()).toISOString() + await postAlert(fetchImpl, options, { ...firingAlert, endsAt: resolvedAt }) + resolutionPosted = true + await waitForAlertState(fetchImpl, options, testId, false, runtime) + const afterResolved = await waitForSuccessfulNotification(fetchImpl, options, afterFiring, 'resolved', runtime) + + return { + status: 'passed', + testId, + receiver: options.receiver, + alertmanagerUrl: options.alertmanagerUrl, + startsAt, + resolvedAt, + firingWebhookAccepted: true, + resolvedWebhookAccepted: true, + notificationDelta: afterResolved.total - baseline.total, + failedNotificationDelta: afterResolved.failed - baseline.failed, + downstreamProcessingProven: false, + } + } catch (error) { + if (firingPosted && !resolutionPosted) { + try { + await postAlert(fetchImpl, options, { ...firingAlert, endsAt: new Date(now()).toISOString() }) + } catch (cleanupError) { + throw new AlertmanagerSmokeError(`${error instanceof Error ? error.message : String(error)}; cleanup resolution also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, { cause: error }) + } + } + throw error + } +} + +async function main() { + const options = parseAlertmanagerSmokeOptions() + if (options.help) { + process.stdout.write(usage()) + return + } + const report = await runAlertmanagerSmoke(options) + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) + return + } + process.stdout.write(`Alertmanager webhook smoke passed.\nTest ID: ${report.testId}\nReceiver: ${report.receiver}\nFiring and resolved webhooks returned success; confirm downstream processing with the test ID.\n`) +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + process.stderr.write(`Alertmanager smoke failed: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..feb8e7e --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_dir="$(cd "${script_dir}/.." && pwd)" +env_file="${FRAMEFLOW_ENV_FILE:-${repo_dir}/.env.production}" +backup_parent="${1:-${repo_dir}/backups}" +metrics_dir="${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +backup_dir="${backup_parent%/}/frameflow-${timestamp}" +started_at_seconds="$(date +%s)" +metrics_tmp="" + +cleanup() { + if [[ -n "${metrics_tmp}" && -f "${metrics_tmp}" ]]; then + rm -f "${metrics_tmp}" + fi +} +trap cleanup EXIT + +if [[ ! -f "${env_file}" ]]; then + echo "Production environment file not found: ${env_file}" >&2 + exit 1 +fi + +compose=(docker compose --env-file "${env_file}" -f "${repo_dir}/docker-compose.prod.yml") +mkdir -p "${backup_parent}" +if ! mkdir "${backup_dir}"; then + echo "Refusing to overwrite an existing backup directory: ${backup_dir}" >&2 + exit 1 +fi + +postgres_container="$("${compose[@]}" ps -q postgres)" +redis_container="$("${compose[@]}" ps -q redis)" +minio_container="$("${compose[@]}" ps -q minio)" + +for container in "${postgres_container}" "${redis_container}" "${minio_container}"; do + if [[ -z "${container}" ]]; then + echo "PostgreSQL, Redis, and MinIO must be running before backup." >&2 + exit 1 + fi +done + +echo "Creating PostgreSQL logical backup..." +"${compose[@]}" exec -T postgres sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "${backup_dir}/postgres.dump" + +echo "Flushing and archiving Redis persistence..." +"${compose[@]}" exec -T redis sh -c ' + if [ -n "${REDIS_PASSWORD:-}" ]; then + redis-cli --no-auth-warning -a "$REDIS_PASSWORD" SAVE >/dev/null + else + redis-cli SAVE >/dev/null + fi +' +docker run --rm --volumes-from "${redis_container}" -v "${backup_dir}:/backup" alpine:3.20 \ + tar -czf /backup/redis-data.tar.gz -C /data . + +echo "Archiving MinIO object data..." +docker run --rm --volumes-from "${minio_container}" -v "${backup_dir}:/backup" alpine:3.20 \ + tar -czf /backup/minio-data.tar.gz -C /data . + +{ + echo "created_at=${timestamp}" + echo "compose_project=$(docker inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}' "${postgres_container}")" + echo "postgres_container=${postgres_container}" + echo "redis_container=${redis_container}" + echo "minio_container=${minio_container}" +} > "${backup_dir}/manifest.txt" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "${backup_dir}" && sha256sum postgres.dump redis-data.tar.gz minio-data.tar.gz > SHA256SUMS) +else + (cd "${backup_dir}" && shasum -a 256 postgres.dump redis-data.tar.gz minio-data.tar.gz > SHA256SUMS) +fi + +"${repo_dir}/scripts/verify-backup.sh" "${backup_dir}" + +completed_at_seconds="$(date +%s)" +backup_size_bytes=0 +for backup_file in postgres.dump redis-data.tar.gz minio-data.tar.gz manifest.txt SHA256SUMS; do + file_size="$(wc -c < "${backup_dir}/${backup_file}" | tr -d '[:space:]')" + backup_size_bytes=$((backup_size_bytes + file_size)) +done + +mkdir -p "${metrics_dir}" +metrics_tmp="$(mktemp "${metrics_dir%/}/frameflow_backup.prom.tmp.XXXXXX")" +cat > "${metrics_tmp}" <&2 <<'EOF' +Usage: rehearse-restore.sh [--keep-on-failure] + +Restores a verified FrameFlow backup into isolated, randomly named Docker +resources. No host ports are published. Resources are removed automatically +unless --keep-on-failure is supplied and the rehearsal fails. +EOF + exit "${1:-2}" +} + +fail() { + echo "Restore rehearsal failed: $1" >&2 + exit 1 +} + +keep_on_failure=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --keep-on-failure) + keep_on_failure=1 + shift + ;; + --help|-h) + usage 0 + ;; + --) + shift + break + ;; + -*) + usage + ;; + *) + break + ;; + esac +done + +[[ $# -eq 1 ]] || usage + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +backup_input="$1" +timeout_seconds="${FRAMEFLOW_RESTORE_TIMEOUT_SECONDS:-120}" +metrics_dir="${FRAMEFLOW_RESTORE_METRICS_DIR:-${FRAMEFLOW_BACKUP_METRICS_DIR:-/var/lib/frameflow/metrics}}" +started_at_seconds="$(date +%s)" +metrics_tmp="" + +[[ "${timeout_seconds}" =~ ^[0-9]+$ ]] || fail "FRAMEFLOW_RESTORE_TIMEOUT_SECONDS must be an integer" +(( timeout_seconds >= 5 && timeout_seconds <= 3600 )) \ + || fail "FRAMEFLOW_RESTORE_TIMEOUT_SECONDS must be between 5 and 3600" + +# Verification deliberately precedes every Docker call. +"${script_dir}/verify-backup.sh" "${backup_input}" +backup_dir="$(cd "${backup_input}" && pwd -P)" + +command -v docker >/dev/null 2>&1 || fail "docker is required" +docker version >/dev/null 2>&1 || fail "the Docker engine is unavailable" + +random_hex() { + local bytes="$1" + local value + value="$(LC_ALL=C od -An -N "${bytes}" -tx1 /dev/urandom | tr -d '[:space:]')" + [[ "${value}" =~ ^[0-9a-f]+$ ]] || fail "could not generate a random resource identifier" + printf '%s' "${value}" +} + +timestamp="$(date -u +%Y%m%d%H%M%S)" +suffix="$(random_hex 4)" +run_id="${timestamp}-${suffix}" +prefix="frameflow-restore-${run_id}" +label_flag="com.frameflow.restore-rehearsal" +label_id="com.frameflow.restore-rehearsal.id" +expected_labels="true|${run_id}" +labels=(--label "${label_flag}=true" --label "${label_id}=${run_id}") + +network_name="${prefix}-network" +postgres_volume="${prefix}-postgres" +redis_volume="${prefix}-redis" +minio_volume="${prefix}-minio" +postgres_container="${prefix}-postgres" +redis_container="${prefix}-redis" +minio_container="${prefix}-minio" +redis_extract_container="${prefix}-redis-extract" +minio_extract_container="${prefix}-minio-extract" +minio_check_container="${prefix}-minio-check" + +postgres_image="postgres:16-alpine" +redis_image="redis:7.4-alpine" +alpine_image="alpine:3.20" +minio_image="minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e" +mc_image="minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727" + +postgres_db="frameflow" +postgres_user="frameflow_restore" +postgres_password="$(random_hex 24)" +redis_password="$(random_hex 24)" +minio_access_key="restore${suffix}" +minio_secret_key="$(random_hex 32)" + +resources_cleaned=0 + +resource_labels() { + local kind="$1" + local name="$2" + case "${kind}" in + container) + docker container inspect --format "{{ index .Config.Labels \"${label_flag}\" }}|{{ index .Config.Labels \"${label_id}\" }}" "${name}" 2>/dev/null + ;; + network|volume) + docker "${kind}" inspect --format "{{ index .Labels \"${label_flag}\" }}|{{ index .Labels \"${label_id}\" }}" "${name}" 2>/dev/null + ;; + *) + return 2 + ;; + esac +} + +remove_labeled_resource() { + local kind="$1" + local name="$2" + local actual + + if ! actual="$(resource_labels "${kind}" "${name}")"; then + return 0 + fi + if [[ "${actual}" != "${expected_labels}" ]]; then + echo "Refusing to remove ${kind} ${name}: rehearsal label mismatch" >&2 + return 1 + fi + + case "${kind}" in + container) docker rm -f "${name}" >/dev/null ;; + network) docker network rm "${name}" >/dev/null ;; + volume) docker volume rm "${name}" >/dev/null ;; + esac +} + +cleanup_resources() { + local result=0 + local name + + for name in \ + "${minio_check_container}" \ + "${minio_extract_container}" \ + "${redis_extract_container}" \ + "${minio_container}" \ + "${redis_container}" \ + "${postgres_container}"; do + if ! remove_labeled_resource container "${name}"; then + result=1 + fi + done + + if ! remove_labeled_resource network "${network_name}"; then + result=1 + fi + for name in "${minio_volume}" "${redis_volume}" "${postgres_volume}"; do + if ! remove_labeled_resource volume "${name}"; then + result=1 + fi + done + return "${result}" +} + +cleanup_on_exit() { + local status=$? + trap - EXIT + + if [[ -n "${metrics_tmp}" && -f "${metrics_tmp}" ]]; then + rm -f "${metrics_tmp}" + fi + + if (( status != 0 && keep_on_failure == 1 )); then + cat >&2 </dev/null 2>&1; do + (( SECONDS < deadline )) || fail "PostgreSQL did not become ready within ${timeout_seconds} seconds" + sleep 1 + done +} + +wait_for_redis() { + local deadline=$((SECONDS + timeout_seconds)) + until docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" ping 2>/dev/null | grep -q '^PONG$'; do + (( SECONDS < deadline )) || fail "Redis did not become ready within ${timeout_seconds} seconds" + sleep 1 + done +} + +wait_for_minio() { + local deadline=$((SECONDS + timeout_seconds)) + until docker exec "${minio_container}" curl -fsS http://127.0.0.1:9000/minio/health/live >/dev/null 2>&1; do + (( SECONDS < deadline )) || fail "MinIO did not become ready within ${timeout_seconds} seconds" + sleep 1 + done +} + +echo "Creating isolated restore resources for rehearsal ${run_id}..." +docker network create --internal "${labels[@]}" "${network_name}" >/dev/null +docker volume create "${labels[@]}" "${postgres_volume}" >/dev/null +docker volume create "${labels[@]}" "${redis_volume}" >/dev/null +docker volume create "${labels[@]}" "${minio_volume}" >/dev/null + +echo "Restoring PostgreSQL into an empty rehearsal volume..." +docker run -d \ + --name "${postgres_container}" \ + --network "${network_name}" \ + "${labels[@]}" \ + -e POSTGRES_DB="${postgres_db}" \ + -e POSTGRES_USER="${postgres_user}" \ + -e POSTGRES_PASSWORD="${postgres_password}" \ + -v "${postgres_volume}:/var/lib/postgresql/data" \ + "${postgres_image}" >/dev/null +wait_for_postgres +docker exec -i "${postgres_container}" \ + pg_restore -U "${postgres_user}" -d "${postgres_db}" \ + --exit-on-error --no-owner --no-privileges \ + < "${backup_dir}/postgres.dump" + +core_tables_ok="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \ + "select case when to_regclass('drizzle.__drizzle_migrations') is not null + and to_regclass('public.users') is not null + and to_regclass('public.workspaces') is not null + and to_regclass('public.projects') is not null + and to_regclass('public.episodes') is not null + and to_regclass('public.assets') is not null + and to_regclass('public.asset_versions') is not null + and to_regclass('public.shots') is not null + and to_regclass('public.shot_versions') is not null + and to_regclass('public.generation_jobs') is not null + and to_regclass('public.renders') is not null + and to_regclass('public.provider_verifications') is not null + then 'ok' else 'missing' end")" +[[ "${core_tables_ok}" == "ok" ]] || fail "PostgreSQL restore is missing required tables" + +docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \ + "select count(*) from users; + select count(*) from workspaces; + select count(*) from projects; + select count(*) from episodes; + select count(*) from assets; + select count(*) from shots; + select count(*) from generation_jobs; + select count(*) from renders; + select count(*) from provider_verifications;" >/dev/null + +migration_count="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \ + 'select count(*) from drizzle.__drizzle_migrations')" +table_count="$(docker exec "${postgres_container}" psql -v ON_ERROR_STOP=1 -U "${postgres_user}" -d "${postgres_db}" -Atc \ + "select count(*) from pg_tables where schemaname in ('public', 'drizzle')")" +[[ "${migration_count}" =~ ^[0-9]+$ && "${table_count}" =~ ^[0-9]+$ ]] \ + || fail "PostgreSQL verification returned invalid counts" +(( migration_count > 0 )) || fail "PostgreSQL migration history is empty" + +echo "Restoring Redis persistence into an empty rehearsal volume..." +docker run --rm \ + --name "${redis_extract_container}" \ + "${labels[@]}" \ + -v "${redis_volume}:/target" \ + --mount "type=bind,src=${backup_dir},dst=/backup,readonly" \ + --entrypoint /bin/sh \ + "${alpine_image}" -ec \ + 'test -z "$(find /target -mindepth 1 -maxdepth 1 -print -quit)" && tar -xzf /backup/redis-data.tar.gz -C /target' +docker run -d \ + --name "${redis_container}" \ + --network "${network_name}" \ + "${labels[@]}" \ + -v "${redis_volume}:/data" \ + "${redis_image}" redis-server --appendonly yes --requirepass "${redis_password}" >/dev/null +wait_for_redis +redis_key_count="$(docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" --raw DBSIZE)" +redis_persistence="$(docker exec "${redis_container}" redis-cli --no-auth-warning -a "${redis_password}" --raw INFO persistence | tr -d '\r')" +[[ "${redis_key_count}" =~ ^[0-9]+$ ]] || fail "Redis verification returned an invalid key count" +grep -q '^loading:0$' <<< "${redis_persistence}" || fail "Redis is still loading restored persistence" +grep -q '^aof_last_write_status:ok$' <<< "${redis_persistence}" || fail "Redis AOF persistence is not healthy" + +echo "Restoring MinIO data into an empty rehearsal volume..." +docker run --rm \ + --name "${minio_extract_container}" \ + "${labels[@]}" \ + -v "${minio_volume}:/target" \ + --mount "type=bind,src=${backup_dir},dst=/backup,readonly" \ + --entrypoint /bin/sh \ + "${alpine_image}" -ec \ + 'test -z "$(find /target -mindepth 1 -maxdepth 1 -print -quit)" && tar -xzf /backup/minio-data.tar.gz -C /target' +docker run -d \ + --name "${minio_container}" \ + --network "${network_name}" \ + "${labels[@]}" \ + -e MINIO_ROOT_USER="${minio_access_key}" \ + -e MINIO_ROOT_PASSWORD="${minio_secret_key}" \ + -v "${minio_volume}:/data" \ + "${minio_image}" server /data --console-address :9001 >/dev/null +wait_for_minio +minio_object_count="$(docker run --rm \ + --name "${minio_check_container}" \ + --network "${network_name}" \ + "${labels[@]}" \ + -e MC_HOST_RESTORE="http://${minio_access_key}:${minio_secret_key}@${minio_container}:9000" \ + --entrypoint /bin/sh \ + "${mc_image}" -ec \ + 'mc admin info RESTORE >/dev/null && mc ls RESTORE >/dev/null && mc ls --recursive RESTORE | wc -l | tr -d "[:space:]"')" +[[ "${minio_object_count}" =~ ^[0-9]+$ ]] || fail "MinIO verification returned an invalid object count" + +echo "Cleaning isolated restore resources..." +cleanup_resources || fail "one or more rehearsal resources failed label verification or cleanup" +resources_cleaned=1 + +completed_at_seconds="$(date +%s)" +mkdir -p "${metrics_dir}" +metrics_tmp="$(mktemp "${metrics_dir%/}/frameflow_restore.prom.tmp.XXXXXX")" +cat > "${metrics_tmp}" <&2 <<'EOF' +Usage: scheduled-maintenance.sh + +Requires FRAMEFLOW_BACKUP_MOUNTPOINT and FRAMEFLOW_BACKUP_DESTINATION. +The destination must be a writable directory inside the active mount point. +EOF + exit "${1:-2}" +} + +fail() { + echo "Scheduled maintenance failed: $1" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +case "$1" in + backup|restore-latest) mode="$1" ;; + --help|-h) usage 0 ;; + *) usage ;; +esac + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +mount_point="${FRAMEFLOW_BACKUP_MOUNTPOINT:-}" +backup_destination="${FRAMEFLOW_BACKUP_DESTINATION:-}" + +[[ -n "${mount_point}" ]] || fail "FRAMEFLOW_BACKUP_MOUNTPOINT is required" +[[ -n "${backup_destination}" ]] || fail "FRAMEFLOW_BACKUP_DESTINATION is required" + +for path in "${mount_point}" "${backup_destination}"; do + [[ "${path}" == /* ]] || fail "backup paths must be absolute: ${path}" + [[ "${path}" != "/" ]] || fail "the filesystem root cannot be used for backups" + [[ ! "${path}" =~ [[:space:]] ]] || fail "backup paths must not contain whitespace: ${path}" + [[ -d "${path}" ]] || fail "backup directory does not exist: ${path}" + [[ ! -L "${path}" ]] || fail "backup paths must not be symbolic links: ${path}" +done + +mount_point="$(cd "${mount_point}" && pwd -P)" +backup_destination="$(cd "${backup_destination}" && pwd -P)" +if [[ "${backup_destination}" != "${mount_point}" && "${backup_destination}" != "${mount_point}/"* ]]; then + fail "backup destination must be inside FRAMEFLOW_BACKUP_MOUNTPOINT" +fi +[[ -w "${backup_destination}" ]] || fail "backup destination is not writable: ${backup_destination}" + +command -v findmnt >/dev/null 2>&1 || fail "findmnt is required to verify the backup mount" +mounted_target="$(findmnt -rn -M "${mount_point}" -o TARGET 2>/dev/null)" \ + || fail "backup mount is not active: ${mount_point}" +[[ "${mounted_target}" == "${mount_point}" ]] \ + || fail "backup mount resolved to an unexpected target: ${mounted_target}" + +case "${mode}" in + backup) + exec "${script_dir}/backup.sh" "${backup_destination}" + ;; + restore-latest) + latest_name="" + latest_path="" + while IFS= read -r -d '' candidate; do + [[ ! -L "${candidate}" ]] || continue + candidate_name="${candidate##*/}" + [[ "${candidate_name}" =~ ^frameflow-[0-9]{8}T[0-9]{6}Z$ ]] || continue + if [[ -z "${latest_name}" || "${candidate_name}" > "${latest_name}" ]]; then + latest_name="${candidate_name}" + latest_path="${candidate}" + fi + done < <(find "${backup_destination}" -mindepth 1 -maxdepth 1 -type d -name 'frameflow-*' -print0) + + [[ -n "${latest_path}" ]] || fail "no timestamped FrameFlow backup was found" + echo "Selected latest backup for restore rehearsal: ${latest_path}" + "${script_dir}/verify-backup.sh" "${latest_path}" + exec "${script_dir}/rehearse-restore.sh" "${latest_path}" + ;; +esac diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs new file mode 100755 index 0000000..9eada6e --- /dev/null +++ b/scripts/smoke-test.mjs @@ -0,0 +1,927 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { basename, extname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptPath = fileURLToPath(import.meta.url) +const defaultImagePath = fileURLToPath(new URL('../public/assets/scene-rooftop.jpg', import.meta.url)) +const terminalJobStatuses = new Set(['SUCCEEDED', 'FAILED', 'CANCELLED']) +const billableAiAcknowledgement = 'I_ACCEPT_BILLABLE_AI_CHARGES' +export const requiredLiveAiWorkflowIds = ['script', 'storyboard', 'image-openai', 'image-replicate', 'tts', 'video', 'lipsync'] + +export class SmokeTestError extends Error { + constructor(message, options = {}) { + super(message, options) + this.name = 'SmokeTestError' + this.report = options.report + } +} + +function required(value, name) { + if (typeof value !== 'string' || value.trim() === '') throw new SmokeTestError(`${name} is required`) + return value.trim() +} + +function booleanValue(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()) +} + +function integerValue(value, name, minimum, maximum) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new SmokeTestError(`${name} must be an integer from ${minimum} to ${maximum}`) + } + return parsed +} + +function jsonObjectValue(value, name) { + if (value === undefined || value === null || String(value).trim() === '') return {} + let parsed + try { + parsed = JSON.parse(String(value)) + } catch { + throw new SmokeTestError(`${name} must be valid JSON`) + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new SmokeTestError(`${name} must be a JSON object`) + } + return parsed +} + +export function resolveEndpoints(value, options = {}) { + const input = required(value, 'FRAMEFLOW_SMOKE_BASE_URL') + let url + try { + url = new URL(input) + } catch { + throw new SmokeTestError('FRAMEFLOW_SMOKE_BASE_URL must be a valid URL') + } + if (url.username || url.password || url.search || url.hash) { + throw new SmokeTestError('Smoke-test base URL cannot contain credentials, a query, or a fragment') + } + if (!['http:', 'https:'].includes(url.protocol)) throw new SmokeTestError('Smoke-test base URL must use HTTP or HTTPS') + const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + if (url.protocol === 'http:' && (!options.allowHttp || !localHostnames.has(url.hostname))) { + throw new SmokeTestError('HTTPS is required; HTTP can only be enabled explicitly for localhost') + } + const path = url.pathname.replace(/\/+$/, '') + if (path && path !== '/api/v1') { + throw new SmokeTestError('Smoke-test base URL path must be empty or /api/v1') + } + const origin = url.origin + return { origin, apiBase: `${origin}/api/v1` } +} + +export function parseSmokeOptions(argv = process.argv.slice(2), environment = process.env) { + const values = {} + const flags = new Set() + const valueOptions = new Set(['--base-url', '--email', '--workspace-id', '--image', '--timeout-seconds', '--ai-video-seconds']) + const flagOptions = new Set(['--allow-http', '--keep-project', '--include-ai', '--json', '--help']) + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (valueOptions.has(argument)) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new SmokeTestError(`${argument} requires a value`) + values[argument] = value + index += 1 + } else if (flagOptions.has(argument)) { + flags.add(argument) + } else { + throw new SmokeTestError(`Unknown option: ${argument}`) + } + } + if (flags.has('--help')) return { help: true } + + const allowHttp = flags.has('--allow-http') || booleanValue(environment.FRAMEFLOW_SMOKE_ALLOW_HTTP) + const endpoints = resolveEndpoints(values['--base-url'] ?? environment.FRAMEFLOW_SMOKE_BASE_URL, { allowHttp }) + const email = required(values['--email'] ?? environment.FRAMEFLOW_SMOKE_EMAIL, 'FRAMEFLOW_SMOKE_EMAIL').toLowerCase() + const password = required(environment.FRAMEFLOW_SMOKE_PASSWORD, 'FRAMEFLOW_SMOKE_PASSWORD') + const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_SMOKE_TIMEOUT_SECONDS ?? '180', 'timeout-seconds', 30, 900) + const includeAi = flags.has('--include-ai') + if (includeAi && environment.FRAMEFLOW_SMOKE_BILLABLE_AI_ACK !== billableAiAcknowledgement) { + throw new SmokeTestError(`--include-ai requires FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=${billableAiAcknowledgement}`) + } + return { + ...endpoints, + email, + password, + workspaceId: values['--workspace-id'] ?? environment.FRAMEFLOW_SMOKE_WORKSPACE_ID ?? null, + imagePath: resolve(values['--image'] ?? environment.FRAMEFLOW_SMOKE_IMAGE ?? defaultImagePath), + timeoutMs: timeoutSeconds * 1000, + requestTimeoutMs: Math.min(60_000, timeoutSeconds * 1000), + keepProject: flags.has('--keep-project') || booleanValue(environment.FRAMEFLOW_SMOKE_KEEP_PROJECT), + includeAi, + aiVideoSeconds: integerValue(values['--ai-video-seconds'] ?? environment.FRAMEFLOW_SMOKE_AI_VIDEO_SECONDS ?? '5', 'ai-video-seconds', 1, 30), + aiVideoParams: jsonObjectValue(environment.FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON, 'FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON'), + aiLipsyncParams: jsonObjectValue(environment.FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON, 'FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON'), + json: flags.has('--json'), + } +} + +function safeError(error) { + const message = error instanceof Error ? error.message : String(error) + return message.replace(/([?&](?:X-Amz-[^=\s]+|token|signature)=)[^&\s]+/gi, '$1[redacted]').slice(0, 2000) +} + +function assertCondition(condition, message) { + if (!condition) throw new SmokeTestError(message) +} + +function sha256(value) { + return createHash('sha256').update(value).digest('hex') +} + +function imageContentType(path) { + const extension = extname(path).toLowerCase() + if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg' + if (extension === '.png') return 'image/png' + if (extension === '.webp') return 'image/webp' + throw new SmokeTestError('Smoke-test image must be JPEG, PNG, or WebP') +} + +function responseBody(response) { + if (response.status === 204) return Promise.resolve(null) + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) return response.json() + return response.text() +} + +async function fetchWithTimeout(fetchImpl, url, options, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) + try { + return await fetchImpl(url, { ...options, signal: controller.signal }) + } finally { + clearTimeout(timer) + } +} + +function createApiClient(options, fetchImpl) { + let accessToken = null + const cookies = new Map() + + function captureCookies(headers) { + const values = typeof headers.getSetCookie === 'function' + ? headers.getSetCookie() + : headers.get('set-cookie') ? [headers.get('set-cookie')] : [] + for (const value of values) { + const pair = value?.split(';', 1)[0] + const separator = pair?.indexOf('=') ?? -1 + if (!pair || separator < 1) continue + const name = pair.slice(0, separator).trim() + const cookieValue = pair.slice(separator + 1).trim() + if (!cookieValue || /(?:^|;)\s*max-age=0(?:;|$)/i.test(value)) cookies.delete(name) + else cookies.set(name, cookieValue) + } + } + + async function raw(path, request = {}) { + const headers = new Headers(request.headers) + const url = path.startsWith('http://') || path.startsWith('https://') ? path : `${options.apiBase}${path}` + const authenticatedApiRequest = request.auth !== false && (url === options.apiBase || url.startsWith(`${options.apiBase}/`)) + if (accessToken && authenticatedApiRequest) headers.set('authorization', `Bearer ${accessToken}`) + if (authenticatedApiRequest && cookies.size > 0) { + headers.set('cookie', [...cookies].map(([name, value]) => `${name}=${value}`).join('; ')) + } + let body = request.body + if (request.json !== undefined) { + headers.set('content-type', 'application/json') + body = JSON.stringify(request.json) + } + const response = await fetchWithTimeout(fetchImpl, url, { + method: request.method ?? 'GET', + headers, + body, + redirect: request.redirect ?? 'follow', + }, request.timeoutMs ?? options.requestTimeoutMs) + captureCookies(response.headers) + return response + } + async function request(path, input = {}) { + const response = await raw(path, input) + const body = await responseBody(response) + if (!response.ok) { + const message = body?.error?.message ?? (typeof body === 'string' && body) ?? `HTTP ${response.status}` + throw new SmokeTestError(`${input.method ?? 'GET'} ${path} failed (${response.status}): ${message}`) + } + return body + } + return { + raw, + request, + setAccessToken(value) { accessToken = value }, + clearSession() { + accessToken = null + cookies.clear() + }, + } +} + +export async function waitForJob(getJob, jobId, options = {}) { + const timeoutMs = options.timeoutMs ?? 180_000 + const pollIntervalMs = options.pollIntervalMs ?? 500 + const sleep = options.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))) + const now = options.now ?? Date.now + const startedAt = now() + let lastStatus = null + while (now() - startedAt <= timeoutMs) { + const job = await getJob(jobId) + lastStatus = job?.status ?? null + if (lastStatus === 'SUCCEEDED') return job + if (terminalJobStatuses.has(lastStatus)) { + throw new SmokeTestError(`Job ${jobId} ended in ${lastStatus}: ${job.errorMessage ?? job.errorCode ?? 'unknown error'}`) + } + await sleep(pollIntervalMs) + } + throw new SmokeTestError(`Job ${jobId} did not finish within ${timeoutMs}ms (last status: ${lastStatus ?? 'unknown'})`) +} + +export async function waitForNotifications(getNotifications, resourceIds, options = {}) { + const timeoutMs = options.timeoutMs ?? 10_000 + const pollIntervalMs = options.pollIntervalMs ?? 250 + const sleep = options.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))) + const now = options.now ?? Date.now + const expected = new Set(resourceIds) + const startedAt = now() + let lastItems = [] + while (now() - startedAt <= timeoutMs) { + const page = await getNotifications() + lastItems = Array.isArray(page?.items) ? page.items : [] + const matched = lastItems.filter((notification) => expected.has(notification.resourceId)) + if (new Set(matched.map((notification) => notification.resourceId)).size === expected.size) return matched + await sleep(pollIntervalMs) + } + const matchedIds = new Set(lastItems.map((notification) => notification.resourceId)) + const missing = [...expected].filter((resourceId) => !matchedIds.has(resourceId)) + throw new SmokeTestError(`Notifications did not arrive within ${timeoutMs}ms (missing resources: ${missing.join(', ')})`) +} + +function selectWorkspace(workspaces, requestedId) { + assertCondition(Array.isArray(workspaces) && workspaces.length > 0, 'Smoke account has no workspace membership') + const workspace = requestedId + ? workspaces.find((candidate) => candidate.id === requestedId) + : workspaces.length === 1 ? workspaces[0] : null + if (!workspace) { + throw new SmokeTestError(requestedId + ? `Smoke account is not a member of workspace ${requestedId}` + : 'Smoke account belongs to multiple workspaces; set FRAMEFLOW_SMOKE_WORKSPACE_ID') + } + assertCondition(['OWNER', 'ADMIN'].includes(workspace.role), 'Full smoke test requires an OWNER or ADMIN workspace role') + return workspace +} + +async function downloadBytes(fetchImpl, url, timeoutMs, expectedType) { + const response = await fetchWithTimeout(fetchImpl, url, { redirect: 'follow' }, timeoutMs) + assertCondition(response.ok, `Media download failed with HTTP ${response.status}`) + const contentType = response.headers.get('content-type') ?? '' + if (expectedType) assertCondition(contentType.toLowerCase().includes(expectedType), `Unexpected media content type: ${contentType || 'missing'}`) + return Buffer.from(await response.arrayBuffer()) +} + +function readinessSummary(readiness) { + return { + infrastructure: readiness.infrastructure, + ai: { + status: readiness.ai?.status ?? 'UNKNOWN', + verificationMode: readiness.ai?.verificationMode ?? 'UNKNOWN', + summary: readiness.ai?.summary ?? null, + workflows: (readiness.ai?.workflows ?? []).map((workflow) => ({ + id: workflow.id, + provider: workflow.provider, + model: workflow.model, + status: workflow.status, + })), + providers: (readiness.ai?.providers ?? []).map((provider) => ({ + id: provider.id, + status: provider.status, + verification: provider.verification, + configuredCapabilities: provider.configuredCapabilities, + totalCapabilities: provider.totalCapabilities, + })), + }, + } +} + +export function assertLiveAiReady(readiness, minimumCredits = 700, billing = null) { + const capabilityRows = [ + ...(readiness?.ai?.providers ?? []).flatMap((provider) => provider.capabilities ?? []), + ...(readiness?.ai?.workflows ?? []), + ] + const workflows = new Map(capabilityRows.map((workflow) => [workflow.id, workflow])) + const missing = requiredLiveAiWorkflowIds.filter((id) => workflows.get(id)?.status !== 'CONFIGURED') + if (missing.length > 0) { + const details = missing.map((id) => { + const workflow = workflows.get(id) + const requirements = workflow?.missingRequirements?.join(', ') + return requirements ? `${id} (${requirements})` : id + }) + throw new SmokeTestError(`Live AI smoke test requires configured workflows: ${details.join('; ')}`) + } + if (billing && Number(billing.remainingCredits) < minimumCredits) { + throw new SmokeTestError(`Live AI smoke test requires at least ${minimumCredits} remaining credits`) + } + return requiredLiveAiWorkflowIds.map((id) => { + const workflow = workflows.get(id) + return { id, provider: workflow.provider, model: workflow.model } + }) +} + +export async function runProductionSmoke(options, dependencies = {}) { + const fetchImpl = dependencies.fetch ?? fetch + const readFileImpl = dependencies.readFile ?? readFile + const sleep = dependencies.sleep ?? ((milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds))) + const now = dependencies.now ?? Date.now + const log = dependencies.log ?? ((message) => console.error(message)) + const api = createApiClient(options, fetchImpl) + const startedAt = now() + const runId = randomUUID() + const marker = `FRAMEFLOW_SMOKE_${runId}` + const report = { + status: 'running', + runId, + baseUrl: options.origin, + startedAt: new Date(startedAt).toISOString(), + workspaceId: null, + projectId: null, + projectArchived: false, + checks: [], + services: null, + billing: null, + aiVerification: { + mode: options.includeAi ? 'LIVE_PROVIDER_CALLS' : 'CONFIGURATION_ONLY', + jobs: [], + artifacts: {}, + }, + artifacts: {}, + } + let projectId = null + let loggedIn = false + let smokeSessionId = null + let primaryError = null + + async function step(id, label, action, summarize) { + const stepStartedAt = now() + log(`[...] ${label}`) + try { + const value = await action() + const check = { id, label, status: 'passed', durationMs: Math.max(0, now() - stepStartedAt) } + const details = summarize?.(value) + if (details !== undefined) check.details = details + report.checks.push(check) + log(`[ ok] ${label} (${check.durationMs}ms)`) + return value + } catch (error) { + const check = { id, label, status: 'failed', durationMs: Math.max(0, now() - stepStartedAt), error: safeError(error) } + report.checks.push(check) + log(`[fail] ${label}: ${check.error}`) + throw error + } + } + + async function runAiJob(projectIdForJob, episodeIdForJob, type, input) { + const queued = await api.request(`/projects/${projectIdForJob}/jobs`, { + method: 'POST', + json: { + type, + episodeId: episodeIdForJob, + idempotencyKey: `smoke-ai-${runId}-${report.aiVerification.jobs.length}-${type.toLowerCase()}`, + input, + maxAttempts: 1, + }, + }) + const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { + timeoutMs: options.timeoutMs, + sleep, + }) + assertCondition(['openai', 'replicate'].includes(job.provider), `${type} did not record a live AI provider`) + assertCondition(job.providerModel, `${type} did not record a provider model`) + report.aiVerification.jobs.push({ id: job.id, type, provider: job.provider, model: job.providerModel }) + return job + } + + try { + await step('liveness', 'API liveness', async () => { + const response = await fetchWithTimeout(fetchImpl, `${options.origin}/health/live`, {}, options.requestTimeoutMs) + const body = await responseBody(response) + assertCondition(response.ok && body?.status === 'ok', `Liveness failed with HTTP ${response.status}`) + return body + }) + + await step('readiness', 'API dependency readiness', async () => { + const response = await fetchWithTimeout(fetchImpl, `${options.origin}/health/ready`, {}, options.requestTimeoutMs) + const body = await responseBody(response) + assertCondition(response.ok && body?.status === 'ready', `Readiness failed with HTTP ${response.status}`) + const unavailable = Object.entries(body.dependencies ?? {}).filter(([, status]) => status !== 'ok') + assertCondition(unavailable.length === 0, `Unavailable dependencies: ${unavailable.map(([name]) => name).join(', ')}`) + return body + }, (body) => body.dependencies) + + const session = await step('login', 'Dedicated smoke-account login', async () => { + const value = await api.request('/auth/login', { + method: 'POST', + auth: false, + json: { email: options.email, password: options.password }, + }) + assertCondition(value?.accessToken && value?.user?.id, 'Login response is missing the authenticated session') + api.setAccessToken(value.accessToken) + loggedIn = true + const sessions = await api.request('/auth/sessions') + const currentSession = sessions.find((candidate) => candidate.current) + assertCondition(currentSession?.id, 'Login refresh session cookie was not retained by the smoke runner') + smokeSessionId = currentSession.id + return value + }, (value) => ({ userId: value.user.id, sessionId: smokeSessionId, workspaceCount: value.workspaces.length })) + + const workspace = selectWorkspace(session.workspaces, options.workspaceId) + report.workspaceId = workspace.id + const serviceReadiness = await step('service-readiness', 'Worker, storage, FFmpeg, and AI configuration', async () => { + const value = await api.request(`/workspaces/${workspace.id}/service-readiness`) + assertCondition(value?.infrastructure?.status === 'AVAILABLE', 'Authenticated service readiness is degraded') + assertCondition(value.infrastructure.available === value.infrastructure.total, 'Not every production service is available') + return value + }, (value) => ({ + infrastructure: value.infrastructure.services.map((service) => ({ id: service.id, status: service.status, latencyMs: service.latencyMs })), + ai: value.ai.summary, + })) + report.services = readinessSummary(serviceReadiness) + + const billingReadModel = await step('billing', 'Billing and usage read model', async () => { + const value = await api.request(`/workspaces/${workspace.id}/billing/summary`) + assertCondition(value?.plan && value?.provider?.status, 'Billing summary is incomplete') + return value + }, (value) => ({ plan: value.plan, provider: value.provider.status, webhookConfigured: value.provider.webhookConfigured, remainingCredits: value.remainingCredits })) + report.billing = { + plan: billingReadModel.plan, + provider: billingReadModel.provider.status, + webhookConfigured: billingReadModel.provider.webhookConfigured, + remainingCredits: billingReadModel.remainingCredits, + } + + if (options.includeAi) { + await step('ai-preflight', 'Billable AI authorization, configuration, and credit preflight', async () => { + const workflows = assertLiveAiReady(serviceReadiness, 700, billingReadModel) + return { acknowledgement: 'accepted', minimumCredits: 700, workflows } + }, (value) => value) + } + + const project = await step('project', 'Project persistence', async () => api.request(`/workspaces/${workspace.id}/projects`, { + method: 'POST', + json: { + name: `[SMOKE] ${new Date(startedAt).toISOString()} ${runId.slice(0, 8)}`, + logline: 'Automated production smoke test. Safe to archive after completion.', + genre: 'SMOKE_TEST', + visualBible: { smokeTest: { runId, createdAt: new Date(startedAt).toISOString() } }, + }, + }), (value) => ({ projectId: value.id })) + projectId = project.id + report.projectId = projectId + + const episode = await step('script', 'Episode and immutable script persistence', async () => { + const createdEpisode = await api.request(`/projects/${projectId}/episodes`, { + method: 'POST', + json: { episodeNumber: 1, title: 'Production smoke episode', synopsis: marker, targetDurationSeconds: 15 }, + }) + const script = await api.request(`/episodes/${createdEpisode.id}/scripts`, { + method: 'POST', + json: { + title: 'Production smoke script', + content: { smokeTest: true, runId, scenes: [{ number: 1, text: marker }] }, + plainText: `SCENE 1 - SMOKE TEST\n${marker}`, + changeSummary: 'Production smoke test', + }, + }) + const persisted = await api.request(`/episodes/${createdEpisode.id}`) + assertCondition(persisted.currentScript?.id === script.id, 'Current script version was not persisted') + assertCondition(persisted.currentScript?.plainText?.includes(marker), 'Persisted script marker is missing') + return { episode: createdEpisode, script } + }, (value) => ({ episodeId: value.episode.id, scriptVersion: value.script.version })) + + const sourceImage = await readFileImpl(options.imagePath) + const sourceHash = sha256(sourceImage) + const contentType = imageContentType(options.imagePath) + const imageAssetVersion = await step('upload', 'Object upload and signed download integrity', async () => { + const form = new FormData() + form.append('file', new Blob([sourceImage], { type: contentType }), basename(options.imagePath)) + const upload = await api.request(`/projects/${projectId}/uploads`, { method: 'POST', body: form }) + assertCondition(upload.byteSize === sourceImage.length, 'Uploaded object byte size does not match the source') + const asset = await api.request(`/projects/${projectId}/assets`, { + method: 'POST', + json: { type: 'SHOT', name: `Smoke source ${runId.slice(0, 8)}`, description: marker, tags: ['smoke-test'], metadata: { smokeRunId: runId } }, + }) + const version = await api.request(`/assets/${asset.id}/versions`, { + method: 'POST', + json: { storageKey: upload.key, mimeType: upload.contentType, byteSize: upload.byteSize, prompt: marker, provider: 'smoke-test', providerModel: 'uploaded-source-v1' }, + }) + const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`) + const downloaded = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, contentType) + assertCondition(sha256(downloaded) === sourceHash, 'Signed object download does not match the uploaded image') + return { asset, version, upload } + }, (value) => ({ assetId: value.asset.id, assetVersionId: value.version.id, byteSize: value.upload.byteSize, sha256: sourceHash })) + + const shot = await step('shot', 'Shot and immutable media-version persistence', async () => { + const created = await api.request(`/episodes/${episode.episode.id}/shots`, { + method: 'POST', + json: { shotNumber: 1, sceneNumber: 1, title: 'Smoke render shot', description: marker, shotType: '全景', cameraMotion: '静止', durationMs: 2000, characterAssetIds: [], sortOrder: 100 }, + }) + const version = await api.request(`/shots/${created.id}/versions`, { + method: 'POST', + json: { imageAssetVersionId: imageAssetVersion.version.id, subtitleText: marker, generationParams: { smokeRunId: runId } }, + }) + const persisted = await api.request(`/shots/${created.id}`) + assertCondition(persisted.versions?.[0]?.id === version.id, 'Current shot version was not persisted') + return { shot: created, version } + }, (value) => ({ shotId: value.shot.id, shotVersion: value.version.version })) + + await step('queue', 'Redis/BullMQ worker execution', async () => { + const queued = await api.request(`/projects/${projectId}/jobs`, { + method: 'POST', + json: { type: 'PIPELINE_HEALTHCHECK', episodeId: episode.episode.id, idempotencyKey: `smoke-health-${runId}`, input: { marker }, maxAttempts: 1 }, + }) + const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep }) + assertCondition(job.output?.ok === true && job.output?.echo?.marker === marker, 'Worker healthcheck output is invalid') + return job + }, (job) => ({ jobId: job.id, provider: job.provider, providerModel: job.providerModel })) + + const renderResult = await step('render', 'FFmpeg render and immutable source manifest', async () => { + const queued = await api.request(`/episodes/${episode.episode.id}/renders`, { + method: 'POST', + json: { + idempotencyKey: `smoke-render-${runId}`, + settings: { width: 640, height: 360, fps: 24, quality: 'draft', burnSubtitles: false, normalizeAudio: false, musicVolume: 0, subtitleStyle: { fontSize: 20, textColor: '#FFFFFF', outlineColor: '#000000', outlineWidth: 2, bottomMargin: 24, bold: true } }, + }, + }) + const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep }) + const render = await api.request(`/renders/${queued.render.id}`) + assertCondition(render.status === 'REVIEW', `Render ended in unexpected status ${render.status}`) + assertCondition(render.sourceShotCount === 1, 'Render source manifest does not contain exactly one shot') + assertCondition(render.subtitleFormats?.includes('srt') && render.subtitleFormats?.includes('vtt'), 'Render subtitle sidecars are missing') + assertCondition(render.downloadUrl, 'Render download URL is missing') + const video = await downloadBytes(fetchImpl, render.downloadUrl, options.requestTimeoutMs, 'video/mp4') + assertCondition(video.length > 1000, 'Rendered video is unexpectedly small') + return { render, job, videoHash: sha256(video), videoBytes: video.length } + }, (value) => ({ renderId: value.render.id, jobId: value.job.id, byteSize: value.videoBytes, subtitleFormats: value.render.subtitleFormats, sourceShotCount: value.render.sourceShotCount })) + report.artifacts.renderId = renderResult.render.id + report.artifacts.videoSha256 = renderResult.videoHash + + await step('subtitles', 'SRT and VTT sidecar downloads', async () => { + const results = {} + for (const format of ['srt', 'vtt']) { + const subtitle = await api.request(`/renders/${renderResult.render.id}/subtitles/${format}`) + const bytes = await downloadBytes(fetchImpl, subtitle.downloadUrl, options.requestTimeoutMs) + const text = bytes.toString('utf8') + assertCondition(text.includes(marker), `${format.toUpperCase()} subtitle does not contain the smoke marker`) + results[format] = { bytes: bytes.length, sha256: sha256(bytes) } + } + return results + }, (value) => value) + + await step('review', 'Render review and approval', async () => { + const review = await api.request(`/workspaces/${workspace.id}/reviews`, { + method: 'POST', + json: { targetType: 'RENDER', targetId: renderResult.render.id, assignedToId: session.user.id, summary: `Automated approval for ${marker}` }, + }) + const decision = await api.request(`/reviews/${review.id}/decision`, { + method: 'POST', + json: { status: 'APPROVED', summary: 'Production smoke render passed automated checks.' }, + }) + const approved = await api.request(`/renders/${renderResult.render.id}`) + assertCondition(decision.status === 'APPROVED' && approved.status === 'APPROVED', 'Render approval was not persisted') + return { review, approved } + }, (value) => ({ reviewId: value.review.id, renderStatus: value.approved.status })) + + const deliveryResult = await step('delivery', 'Approved download delivery through the worker', async () => { + const queued = await api.request(`/renders/${renderResult.render.id}/deliveries`, { + method: 'POST', + json: { platform: 'DOWNLOAD', destination: { smokeRunId: runId } }, + }) + const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep }) + const delivery = await api.request(`/deliveries/${queued.delivery.id}`) + assertCondition(delivery.status === 'PUBLISHED' && delivery.downloadUrl, 'Download delivery was not published') + const redirect = await api.raw(`/deliveries/${delivery.id}/download`, { redirect: 'manual' }) + assertCondition([301, 302, 303, 307, 308].includes(redirect.status), `Delivery endpoint returned HTTP ${redirect.status} instead of a redirect`) + const location = redirect.headers.get('location') + assertCondition(location, 'Delivery redirect is missing its signed location') + const video = await downloadBytes(fetchImpl, location, options.requestTimeoutMs, 'video/mp4') + assertCondition(sha256(video) === renderResult.videoHash, 'Delivered video differs from the approved render') + return { delivery, job, bytes: video.length } + }, (value) => ({ deliveryId: value.delivery.id, jobId: value.job.id, status: value.delivery.status, byteSize: value.bytes })) + report.artifacts.deliveryId = deliveryResult.delivery.id + + await step('notifications', 'Persistent job notifications and read state', async () => { + const expectedJobIds = [renderResult.job.id, deliveryResult.job.id] + const rows = await waitForNotifications( + () => api.request(`/workspaces/${workspace.id}/notifications?limit=100`), + expectedJobIds, + { timeoutMs: Math.min(options.timeoutMs, 10_000), sleep, now }, + ) + for (const jobId of expectedJobIds) { + const notification = rows.find((candidate) => candidate.resourceId === jobId) + assertCondition(notification?.kind === 'JOB_SUCCEEDED' && notification.tone === 'SUCCESS', `Successful job notification is invalid for ${jobId}`) + const expectedPage = jobId === deliveryResult.job.id ? 'publish' : 'jobs' + assertCondition(notification.page === expectedPage, `Job notification points to ${notification.page} instead of ${expectedPage}`) + const read = await api.request(`/notifications/${notification.id}`, { method: 'PATCH', json: { read: true } }) + assertCondition(read.readAt, `Notification ${notification.id} did not persist its read state`) + } + return rows + }, (rows) => ({ notifications: rows.length, resources: rows.map((notification) => notification.resourceId) })) + + await step('audit', 'Audit-log persistence', async () => { + const page = await api.request(`/workspaces/${workspace.id}/audit-logs?limit=100&actorId=${session.user.id}`) + const actions = new Set((page.items ?? []).map((item) => item.action)) + for (const action of ['project.create', 'upload.create', 'render.create', 'review.decide', 'delivery.create']) { + assertCondition(actions.has(action), `Audit log is missing ${action}`) + } + return { actions: actions.size } + }, (value) => value) + + if (options.includeAi) { + const aiSetup = await step('ai-setup', 'Live AI validation workspace data', async () => { + const aiEpisode = await api.request(`/projects/${projectId}/episodes`, { + method: 'POST', + json: { + episodeNumber: 2, + title: 'Live provider validation', + synopsis: `A single character says a short line in a controlled studio. ${marker}`, + targetDurationSeconds: 15, + }, + }) + const character = await api.request(`/projects/${projectId}/assets`, { + method: 'POST', + json: { + type: 'CHARACTER', + name: `Provider test character ${runId.slice(0, 8)}`, + description: 'Adult presenter, centered portrait, neutral studio lighting, production-safe wardrobe.', + tags: ['smoke-test', 'provider-validation'], + metadata: { smokeRunId: runId }, + }, + }) + return { episode: aiEpisode, character } + }, (value) => ({ episodeId: value.episode.id, characterAssetId: value.character.id })) + + const openAiImage = await step('ai-image-openai', 'OpenAI character image generation', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'ASSET_GENERATE', { + assetId: aiSetup.character.id, + prompt: `Clean comic-drama character reference sheet. Adult presenter, centered bust portrait, neutral gray studio, consistent facial landmarks. ${marker}`, + negativePrompt: 'text, watermark, extra limbs, child', + width: 1024, + height: 1024, + provider: 'openai', + referenceAssetVersionIds: [], + params: { consistencyStrength: 82 }, + }) + const asset = await api.request(`/assets/${aiSetup.character.id}`) + const version = asset.versions?.find((candidate) => candidate.id === job.output?.assetVersionId) + assertCondition(version?.storageKey && version.provider === 'openai', 'OpenAI image version was not persisted as current asset media') + const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`) + const bytes = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, 'image/') + assertCondition(bytes.length > 1000, 'OpenAI image output is unexpectedly small') + return { job, asset, version, bytes } + }, (value) => ({ jobId: value.job.id, assetVersionId: value.version.id, model: value.job.providerModel, byteSize: value.bytes.length })) + report.aiVerification.artifacts.openAiImageAssetVersionId = openAiImage.version.id + + const replicateImage = await step('ai-image-replicate', 'Replicate character-consistency variant', async () => { + const profile = await api.request(`/assets/${aiSetup.character.id}/consistency-profile`, { + method: 'PUT', + json: { + referenceAssetVersionIds: [openAiImage.version.id], + identityPrompt: 'Preserve facial proportions, eye shape, hairstyle, and adult age.', + costumePrompt: 'Preserve the dark production jacket and simple collar.', + stylePrompt: 'Polished cinematic comic-drama illustration.', + negativePrompt: 'identity drift, age change, child, text, watermark', + consistencyStrength: 90, + identityLocked: true, + }, + }) + assertCondition(profile.consistencyProfile?.identityLocked, 'Character identity profile was not locked') + const job = await runAiJob(projectId, aiSetup.episode.id, 'ASSET_GENERATE', { + assetId: aiSetup.character.id, + prompt: `The same adult presenter turns slightly toward camera under warm key light. ${marker}`, + negativePrompt: 'text, watermark', + width: 1024, + height: 1024, + provider: 'replicate', + referenceAssetVersionIds: [openAiImage.version.id], + params: { consistencyStrength: 90 }, + }) + const asset = await api.request(`/assets/${aiSetup.character.id}`) + const version = asset.versions?.find((candidate) => candidate.id === job.output?.assetVersionId) + const appliedProfiles = version?.generationParams?.appliedCharacterConsistencyProfiles + assertCondition(version?.storageKey && version.provider === 'replicate', 'Replicate image version was not persisted') + assertCondition(Array.isArray(appliedProfiles) && appliedProfiles.some((snapshot) => snapshot.assetId === aiSetup.character.id && snapshot.identityLocked), 'Replicate variant did not capture the locked character profile') + assertCondition(version.generationParams?.referenceAssetVersionIds?.includes(openAiImage.version.id), 'Replicate variant did not record its identity reference') + const signed = await api.request(`/assets/${asset.id}/versions/${version.id}/url`) + const bytes = await downloadBytes(fetchImpl, signed.url, options.requestTimeoutMs, 'image/') + assertCondition(bytes.length > 1000, 'Replicate image output is unexpectedly small') + return { job, asset, version, bytes } + }, (value) => ({ jobId: value.job.id, assetVersionId: value.version.id, model: value.job.providerModel, byteSize: value.bytes.length })) + report.aiVerification.artifacts.replicateImageAssetVersionId = replicateImage.version.id + + await step('ai-script', 'OpenAI script generation and immutable persistence', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'SCRIPT_GENERATE', { + provider: 'openai', + includeExistingScript: false, + instruction: 'Write one production-safe interior scene for a 15-second Chinese comic drama. Use one adult presenter, one short spoken line, and a clear visual action.', + }) + const episode = await api.request(`/episodes/${aiSetup.episode.id}`) + assertCondition(episode.currentScript?.id === job.output?.scriptVersionId, 'Generated script was not activated as the current version') + assertCondition(episode.currentScript?.plainText?.trim(), 'Generated script has no plain text') + return { job, script: episode.currentScript } + }, (value) => ({ jobId: value.job.id, scriptVersionId: value.script.id, version: value.script.version, model: value.job.providerModel })) + + const storyboard = await step('ai-storyboard', 'OpenAI storyboard generation and shot persistence', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'STORYBOARD_GENERATE', { + provider: 'openai', + replaceExisting: false, + instruction: `Create the smallest viable storyboard for provider validation. Prefer exactly one shot featuring ${aiSetup.character.name}, with one short spoken line and a total duration near five seconds.`, + }) + const rows = await api.request(`/episodes/${aiSetup.episode.id}/shots`) + assertCondition(Array.isArray(rows) && rows.length > 0, 'Generated storyboard contains no shots') + assertCondition(job.output?.shotCount === rows.length, 'Storyboard job output does not match persisted shots') + const [selected, ...extra] = rows + for (const row of extra) await api.request(`/shots/${row.shot.id}`, { method: 'DELETE' }) + const validationText = 'FrameFlow AI provider validation passed.' + const version = await api.request(`/shots/${selected.shot.id}/versions`, { + method: 'POST', + json: { + imageAssetVersionId: replicateImage.version.id, + subtitleText: validationText, + generationParams: { smokeRunId: runId, source: 'live-provider-smoke' }, + }, + }) + return { job, shot: selected.shot, version, removedShots: extra.length, validationText } + }, (value) => ({ jobId: value.job.id, shotId: value.shot.id, generatedShots: value.job.output.shotCount, removedShots: value.removedShots, model: value.job.providerModel })) + + await step('ai-tts', 'OpenAI TTS generation and audio storage', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'TTS_GENERATE', { + shotId: storyboard.shot.id, + text: storyboard.validationText, + voice: 'alloy', + speed: 1, + provider: 'openai', + }) + const media = await api.request(`/shots/${storyboard.shot.id}/media`) + assertCondition(job.output?.audioStorageKey && media.audioUrl, 'TTS output was not bound to the current shot version') + const bytes = await downloadBytes(fetchImpl, media.audioUrl, options.requestTimeoutMs, 'audio/') + assertCondition(bytes.length > 500, 'TTS audio output is unexpectedly small') + return { job, media, bytes } + }, (value) => ({ jobId: value.job.id, model: value.job.providerModel, byteSize: value.bytes.length })) + + await step('ai-video', 'Replicate image-to-video generation and storage', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'VIDEO_GENERATE', { + shotId: storyboard.shot.id, + prompt: 'Subtle natural head turn and blinking, locked camera, stable adult character identity.', + imageAssetVersionId: replicateImage.version.id, + durationSeconds: options.aiVideoSeconds, + provider: 'replicate', + params: options.aiVideoParams, + }) + const media = await api.request(`/shots/${storyboard.shot.id}/media`) + assertCondition(job.output?.videoStorageKey && media.videoUrl, 'Video output was not bound to the current shot version') + const bytes = await downloadBytes(fetchImpl, media.videoUrl, options.requestTimeoutMs, 'video/') + assertCondition(bytes.length > 1000, 'Generated video output is unexpectedly small') + return { job, media, bytes } + }, (value) => ({ jobId: value.job.id, model: value.job.providerModel, durationSeconds: options.aiVideoSeconds, byteSize: value.bytes.length })) + + const lipsync = await step('ai-lipsync', 'Replicate lip-sync generation and media continuity', async () => { + const job = await runAiJob(projectId, aiSetup.episode.id, 'LIPSYNC_GENERATE', { + shotId: storyboard.shot.id, + provider: 'replicate', + params: options.aiLipsyncParams, + }) + const media = await api.request(`/shots/${storyboard.shot.id}/media`) + assertCondition(job.output?.videoStorageKey && media.videoUrl && media.audioUrl, 'Lip-sync output did not preserve video and audio continuity') + const [video, audio] = await Promise.all([ + downloadBytes(fetchImpl, media.videoUrl, options.requestTimeoutMs, 'video/'), + downloadBytes(fetchImpl, media.audioUrl, options.requestTimeoutMs, 'audio/'), + ]) + assertCondition(video.length > 1000 && audio.length > 500, 'Lip-sync media outputs are unexpectedly small') + return { job, media, video, audio } + }, (value) => ({ jobId: value.job.id, model: value.job.providerModel, videoBytes: value.video.length, audioBytes: value.audio.length })) + report.aiVerification.artifacts.lipsyncShotVersionId = lipsync.media.versionId + + const aiRender = await step('ai-render', 'FFmpeg render from live AI media', async () => { + const queued = await api.request(`/episodes/${aiSetup.episode.id}/renders`, { + method: 'POST', + json: { + idempotencyKey: `smoke-ai-render-${runId}`, + settings: { width: 640, height: 360, fps: 24, quality: 'draft', burnSubtitles: false, normalizeAudio: false, musicVolume: 0, subtitleStyle: { fontSize: 20, textColor: '#FFFFFF', outlineColor: '#000000', outlineWidth: 2, bottomMargin: 24, bold: true } }, + }, + }) + const job = await waitForJob((jobId) => api.request(`/jobs/${jobId}`), queued.job.id, { timeoutMs: options.timeoutMs, sleep }) + const render = await api.request(`/renders/${queued.render.id}`) + assertCondition(job.status === 'SUCCEEDED' && render.status === 'REVIEW', 'AI media render did not finish in review state') + assertCondition(render.sourceShotCount === 1 && render.downloadUrl, 'AI media render source manifest is invalid') + const video = await downloadBytes(fetchImpl, render.downloadUrl, options.requestTimeoutMs, 'video/mp4') + assertCondition(video.length > 1000, 'AI media render is unexpectedly small') + return { job, render, video } + }, (value) => ({ jobId: value.job.id, renderId: value.render.id, sourceShotCount: value.render.sourceShotCount, byteSize: value.video.length })) + report.aiVerification.artifacts.renderId = aiRender.render.id + report.aiVerification.artifacts.videoSha256 = sha256(aiRender.video) + + await step('ai-usage', 'Live AI usage-ledger reconciliation', async () => { + const billingAfter = await api.request(`/workspaces/${workspace.id}/billing/summary`) + const providerJobIds = new Set(report.aiVerification.jobs.map((job) => job.id)) + const billedJobIds = new Set((billingAfter.recent ?? []).filter((entry) => providerJobIds.has(entry.jobId)).map((entry) => entry.jobId)) + const missingJobIds = [...providerJobIds].filter((jobId) => !billedJobIds.has(jobId)) + assertCondition(missingJobIds.length === 0, `Usage ledger is missing ${missingJobIds.length} live AI jobs`) + const creditsUsed = Number(billingAfter.usedCredits) - Number(billingReadModel.usedCredits) + assertCondition(creditsUsed > 0, 'Live AI provider calls did not increase recorded usage') + report.aiVerification.creditsUsed = creditsUsed + return { jobs: providerJobIds.size, ledgerJobs: billedJobIds.size, creditsUsed } + }, (value) => value) + } + } catch (error) { + primaryError = error + } finally { + if (projectId && !options.keepProject) { + try { + await step('cleanup', 'Archive smoke-test project', async () => { + const archived = await api.request(`/projects/${projectId}`, { method: 'DELETE' }) + assertCondition(archived.status === 'ARCHIVED', 'Smoke project was not archived') + report.projectArchived = true + return archived + }, (value) => ({ projectId: value.id, status: value.status })) + } catch (cleanupError) { + if (!primaryError) primaryError = cleanupError + } + } + if (loggedIn) { + try { + await step('logout', 'Revoke smoke-account refresh session', async () => { + await api.request('/auth/logout', { method: 'POST' }) + const sessions = await api.request('/auth/sessions') + assertCondition(!sessions.some((candidate) => candidate.id === smokeSessionId), 'Smoke refresh session remains active after logout') + api.clearSession() + return { sessionId: smokeSessionId, revoked: true } + }, (value) => value) + } catch (logoutError) { + if (!primaryError) primaryError = logoutError + } + } + } + + report.status = primaryError ? 'failed' : 'passed' + report.completedAt = new Date(now()).toISOString() + report.durationMs = Math.max(0, now() - startedAt) + if (options.keepProject && projectId) report.projectArchived = false + if (primaryError) { + report.error = safeError(primaryError) + throw new SmokeTestError(report.error, { cause: primaryError, report }) + } + return report +} + +export function usage() { + return `FrameFlow production smoke test + +Usage: + FRAMEFLOW_SMOKE_BASE_URL=https://studio.example.com \\ + FRAMEFLOW_SMOKE_EMAIL=smoke@example.com \\ + FRAMEFLOW_SMOKE_PASSWORD='...' \\ + npm run smoke:production -- [options] + +Options: + --base-url URL Application origin or /api/v1 URL + --email EMAIL Dedicated verified smoke-account email + --workspace-id UUID Required when the account has multiple workspaces + --image PATH JPEG, PNG, or WebP render source + --timeout-seconds N Per-job timeout from 30 to 900 (default: 180) + --include-ai Run live, billable OpenAI and Replicate validation + --ai-video-seconds N Live AI video duration from 1 to 30 (default: 5) + --keep-project Keep the generated smoke project instead of archiving it + --allow-http Allow HTTP only for localhost development + --json Print the complete report as JSON + --help Show this help + +The password is accepted only through FRAMEFLOW_SMOKE_PASSWORD so it is not exposed in the process list. +Default mode never invokes AI provider jobs; configuration-only readiness is included in the report. + +--include-ai starts real provider jobs that can incur charges. It is accepted only when +FRAMEFLOW_SMOKE_BILLABLE_AI_ACK=I_ACCEPT_BILLABLE_AI_CHARGES is also set, all seven AI +workflows are configured, and the workspace has at least 700 remaining credits. Provider +jobs use maxAttempts=1. Optional model-specific JSON objects can be supplied through +FRAMEFLOW_SMOKE_VIDEO_PARAMS_JSON and FRAMEFLOW_SMOKE_LIPSYNC_PARAMS_JSON.` +} + +async function main() { + let options + try { + options = parseSmokeOptions() + if (options.help) { + console.log(usage()) + return + } + const report = await runProductionSmoke(options) + if (options.json) console.log(JSON.stringify(report, null, 2)) + else console.log(`Production smoke test passed in ${report.durationMs}ms. Project ${report.projectId}${report.projectArchived ? ' was archived' : ' was retained'}.`) + } catch (error) { + const report = error instanceof SmokeTestError ? error.report : null + if (options?.json && report) console.error(JSON.stringify(report, null, 2)) + else console.error(`Production smoke test failed: ${safeError(error)}`) + process.exitCode = 1 + } +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) await main() diff --git a/scripts/stripe-smoke.mjs b/scripts/stripe-smoke.mjs new file mode 100755 index 0000000..f8ce38f --- /dev/null +++ b/scripts/stripe-smoke.mjs @@ -0,0 +1,305 @@ +#!/usr/bin/env node + +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import Stripe from 'stripe' + +const scriptPath = fileURLToPath(import.meta.url) +export const requiredStripeWebhookEvents = [ + 'checkout.session.completed', + 'customer.subscription.created', + 'customer.subscription.updated', + 'customer.subscription.deleted', +] + +export class StripeSmokeError extends Error { + constructor(message, options = {}) { + super(message, options) + this.name = 'StripeSmokeError' + } +} + +function required(value, name) { + if (typeof value !== 'string' || value.trim() === '') throw new StripeSmokeError(`${name} is required`) + return value.trim() +} + +function integerValue(value, name, minimum, maximum) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new StripeSmokeError(`${name} must be an integer from ${minimum} to ${maximum}`) + } + return parsed +} + +function booleanValue(value) { + return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()) +} + +export function resolveStripeAppOrigin(value, options = {}) { + let url + try { + url = new URL(required(value, 'FRAMEFLOW_STRIPE_APP_ORIGIN')) + } catch (error) { + if (error instanceof StripeSmokeError) throw error + throw new StripeSmokeError('FRAMEFLOW_STRIPE_APP_ORIGIN must be a valid URL') + } + if (url.username || url.password || url.search || url.hash || url.pathname !== '/') { + throw new StripeSmokeError('Stripe app origin must be a bare origin without credentials, path, query, or fragment') + } + if (!['http:', 'https:'].includes(url.protocol)) throw new StripeSmokeError('Stripe app origin must use HTTP or HTTPS') + const localHostnames = new Set(['localhost', '127.0.0.1', '::1', '[::1]']) + if (url.protocol === 'http:' && (!options.allowHttp || !localHostnames.has(url.hostname))) { + throw new StripeSmokeError('HTTPS is required; HTTP can only be enabled explicitly for localhost') + } + return url.origin +} + +export function usage() { + return `Usage: npm run smoke:stripe -- [options] + +Performs read-only Stripe account, recurring Price, Product, Customer Portal, +and webhook-endpoint configuration checks. It never creates a customer, +Checkout Session, Portal Session, charge, subscription, or webhook event. + +Required environment: + STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET + STRIPE_PRO_PRICE_ID + STRIPE_STUDIO_PRICE_ID + FRAMEFLOW_STRIPE_APP_ORIGIN (falls back to API_PUBLIC_ORIGIN or WEB_ORIGIN) + +Optional environment: + STRIPE_PORTAL_CONFIGURATION_ID + FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS=30 + +Options: + --app-origin + --timeout-seconds <10-120> + --allow-http Loopback development only + --require-live Reject test keys and test-mode Stripe resources + --json + --help + +The signing secret is never sent or printed. Stripe does not expose webhook +signing secrets through its API, so a passing report proves endpoint/event +configuration but not that STRIPE_WEBHOOK_SECRET matches that endpoint. +` +} + +export function parseStripeSmokeOptions(argv = process.argv.slice(2), environment = process.env) { + const values = {} + const flags = new Set() + const valueOptions = new Set(['--app-origin', '--timeout-seconds']) + const flagOptions = new Set(['--allow-http', '--require-live', '--json', '--help']) + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (valueOptions.has(argument)) { + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new StripeSmokeError(`${argument} requires a value`) + values[argument] = value + index += 1 + } else if (flagOptions.has(argument)) { + flags.add(argument) + } else { + throw new StripeSmokeError(`Unknown option: ${argument}`) + } + } + if (flags.has('--help')) return { help: true } + + const secretKey = required(environment.STRIPE_SECRET_KEY, 'STRIPE_SECRET_KEY') + const keyMatch = /^(?:sk|rk)_(test|live)_/.exec(secretKey) + if (!keyMatch) throw new StripeSmokeError('STRIPE_SECRET_KEY must be a Stripe secret or restricted key') + const mode = keyMatch[1] + const requireLive = flags.has('--require-live') || booleanValue(environment.FRAMEFLOW_STRIPE_REQUIRE_LIVE) + if (requireLive && mode !== 'live') throw new StripeSmokeError('--require-live requires a live-mode Stripe key') + + const webhookSecret = required(environment.STRIPE_WEBHOOK_SECRET, 'STRIPE_WEBHOOK_SECRET') + if (!webhookSecret.startsWith('whsec_')) throw new StripeSmokeError('STRIPE_WEBHOOK_SECRET must use the Stripe whsec_ format') + const proPriceId = required(environment.STRIPE_PRO_PRICE_ID, 'STRIPE_PRO_PRICE_ID') + const studioPriceId = required(environment.STRIPE_STUDIO_PRICE_ID, 'STRIPE_STUDIO_PRICE_ID') + if (!proPriceId.startsWith('price_') || !studioPriceId.startsWith('price_')) { + throw new StripeSmokeError('Stripe plan IDs must use the price_ format') + } + if (proPriceId === studioPriceId) throw new StripeSmokeError('PRO and STUDIO must use different Stripe Price IDs') + const portalConfigurationId = environment.STRIPE_PORTAL_CONFIGURATION_ID?.trim() || null + if (portalConfigurationId && !portalConfigurationId.startsWith('bpc_')) { + throw new StripeSmokeError('STRIPE_PORTAL_CONFIGURATION_ID must use the bpc_ format') + } + + const allowHttp = flags.has('--allow-http') || booleanValue(environment.FRAMEFLOW_STRIPE_ALLOW_HTTP) + const originValue = values['--app-origin'] + ?? environment.FRAMEFLOW_STRIPE_APP_ORIGIN + ?? environment.API_PUBLIC_ORIGIN + ?? environment.WEB_ORIGIN?.split(',')[0]?.trim() + const timeoutSeconds = integerValue(values['--timeout-seconds'] ?? environment.FRAMEFLOW_STRIPE_SMOKE_TIMEOUT_SECONDS ?? '30', 'timeout-seconds', 10, 120) + return { + secretKey, + webhookSecretConfigured: true, + proPriceId, + studioPriceId, + portalConfigurationId, + appOrigin: resolveStripeAppOrigin(originValue, { allowHttp }), + mode, + requireLive, + timeoutMs: timeoutSeconds * 1000, + json: flags.has('--json'), + } +} + +async function listStripeCollection(fetchPage, parameters = {}) { + const rows = [] + let startingAfter + for (let page = 0; page < 20; page += 1) { + const response = await fetchPage({ ...parameters, limit: 100, ...(startingAfter ? { starting_after: startingAfter } : {}) }) + if (!response || !Array.isArray(response.data)) throw new StripeSmokeError('Stripe returned an invalid list response') + rows.push(...response.data) + if (!response.has_more) return rows + startingAfter = response.data.at(-1)?.id + if (!startingAfter) throw new StripeSmokeError('Stripe pagination returned no continuation ID') + } + throw new StripeSmokeError('Stripe list exceeded the 2,000-resource verification limit') +} + +function assertMode(resource, mode, label) { + if (typeof resource?.livemode !== 'boolean') throw new StripeSmokeError(`${label} did not report livemode`) + if (resource.livemode !== (mode === 'live')) throw new StripeSmokeError(`${label} does not match the Stripe key mode`) +} + +async function verifyPrice(client, priceId, plan, mode) { + const price = await client.prices.retrieve(priceId, { expand: ['product'] }) + if (price?.object !== 'price' || price.id !== priceId) throw new StripeSmokeError(`${plan} Stripe Price could not be retrieved`) + assertMode(price, mode, `${plan} Price`) + if (price.active !== true) throw new StripeSmokeError(`${plan} Stripe Price is not active`) + if (price.type !== 'recurring' || !price.recurring) throw new StripeSmokeError(`${plan} Stripe Price must be recurring`) + if (price.recurring.usage_type !== 'licensed') throw new StripeSmokeError(`${plan} Stripe Price must use licensed recurring usage`) + const product = price.product + if (!product || typeof product === 'string' || product.deleted || product.object !== 'product') { + throw new StripeSmokeError(`${plan} Stripe Product was not expanded or has been deleted`) + } + if (product.active !== true) throw new StripeSmokeError(`${plan} Stripe Product is not active`) + assertMode(product, mode, `${plan} Product`) + return { + plan, + priceId: price.id, + productId: product.id, + productName: product.name, + currency: price.currency, + unitAmount: price.unit_amount, + billingScheme: price.billing_scheme, + interval: price.recurring.interval, + intervalCount: price.recurring.interval_count, + } +} + +async function verifyPortal(client, configurationId, mode) { + let configuration + if (configurationId) { + configuration = await client.billingPortal.configurations.retrieve(configurationId) + } else { + const configurations = await listStripeCollection( + (parameters) => client.billingPortal.configurations.list(parameters), + { active: true }, + ) + configuration = configurations.find((candidate) => candidate.is_default === true) + } + if (!configuration || configuration.object !== 'billing_portal.configuration') { + throw new StripeSmokeError('No active default Stripe Customer Portal configuration was found') + } + if (configuration.active !== true) throw new StripeSmokeError('Stripe Customer Portal configuration is not active') + assertMode(configuration, mode, 'Customer Portal configuration') + return { + id: configuration.id, + isDefault: configuration.is_default === true, + active: true, + } +} + +async function verifyWebhook(client, appOrigin, mode) { + const expectedUrl = `${appOrigin}/api/v1/billing/webhooks/stripe` + const endpoints = await listStripeCollection((parameters) => client.webhookEndpoints.list(parameters)) + const endpoint = endpoints.find((candidate) => candidate.url === expectedUrl && candidate.status === 'enabled') + if (!endpoint) throw new StripeSmokeError(`No enabled Stripe webhook endpoint matches ${expectedUrl}`) + assertMode(endpoint, mode, 'Webhook endpoint') + const enabledEvents = new Set(endpoint.enabled_events ?? []) + const missingEvents = enabledEvents.has('*') + ? [] + : requiredStripeWebhookEvents.filter((event) => !enabledEvents.has(event)) + if (missingEvents.length > 0) { + throw new StripeSmokeError(`Stripe webhook endpoint is missing events: ${missingEvents.join(', ')}`) + } + return { + id: endpoint.id, + url: endpoint.url, + status: endpoint.status, + requiredEvents: requiredStripeWebhookEvents, + } +} + +export async function runStripeSmoke(options, dependencies = {}) { + const client = dependencies.client ?? new Stripe(options.secretKey, { + maxNetworkRetries: 1, + timeout: options.timeoutMs, + }) + const account = await client.accounts.retrieve() + if (!account || account.object !== 'account' || typeof account.id !== 'string') { + throw new StripeSmokeError('Stripe account could not be retrieved') + } + if (options.requireLive && account.charges_enabled !== true) { + throw new StripeSmokeError('Stripe live charges are not enabled for this account') + } + if (options.requireLive && account.details_submitted !== true) { + throw new StripeSmokeError('Stripe account onboarding details are incomplete') + } + + const [pro, studio, portal, webhook] = await Promise.all([ + verifyPrice(client, options.proPriceId, 'PRO', options.mode), + verifyPrice(client, options.studioPriceId, 'STUDIO', options.mode), + verifyPortal(client, options.portalConfigurationId, options.mode), + verifyWebhook(client, options.appOrigin, options.mode), + ]) + return { + status: 'passed', + mode: options.mode, + account: { + id: account.id, + country: account.country ?? null, + defaultCurrency: account.default_currency ?? null, + chargesEnabled: account.charges_enabled === true, + payoutsEnabled: account.payouts_enabled === true, + detailsSubmitted: account.details_submitted === true, + }, + prices: { PRO: pro, STUDIO: studio }, + portal, + webhook, + webhookSigningSecretConfigured: options.webhookSecretConfigured, + webhookSigningSecretMatchedToEndpoint: false, + sideEffectsCreated: false, + } +} + +function safeError(error) { + const message = error instanceof Error ? error.message : String(error) + return message.replace(/(?:sk|rk)_(?:test|live)_[a-zA-Z0-9]+|whsec_[a-zA-Z0-9]+/g, '[redacted]').slice(0, 1000) +} + +async function main() { + const options = parseStripeSmokeOptions() + if (options.help) { + process.stdout.write(usage()) + return + } + const report = await runStripeSmoke(options) + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) + return + } + process.stdout.write(`Stripe read-only smoke passed in ${report.mode} mode.\nAccount: ${report.account.id}\nWebhook: ${report.webhook.url}\nNo Stripe resources were created. Run a signed webhook acceptance event before launch.\n`) +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + main().catch((error) => { + process.stderr.write(`Stripe smoke failed: ${safeError(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh new file mode 100755 index 0000000..9051406 --- /dev/null +++ b/scripts/verify-backup.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +usage() { + echo "Usage: $0 " >&2 + exit 2 +} + +fail() { + echo "Backup verification failed: $1" >&2 + exit 1 +} + +[[ $# -eq 1 ]] || usage +[[ -d "$1" ]] || fail "directory not found: $1" +[[ ! -L "$1" ]] || fail "backup directory must not be a symbolic link" + +backup_dir="$(cd "$1" && pwd -P)" +required_files=(postgres.dump redis-data.tar.gz minio-data.tar.gz manifest.txt SHA256SUMS) + +for filename in "${required_files[@]}"; do + path="${backup_dir}/${filename}" + [[ -f "${path}" ]] || fail "missing ${filename}" + [[ ! -L "${path}" ]] || fail "${filename} must not be a symbolic link" + [[ -s "${path}" ]] || fail "${filename} is empty" +done + +created_at="$(sed -n 's/^created_at=//p' "${backup_dir}/manifest.txt")" +compose_project="$(sed -n 's/^compose_project=//p' "${backup_dir}/manifest.txt")" +[[ "${created_at}" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || fail "manifest has an invalid created_at value" +[[ "${compose_project}" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ ]] || fail "manifest has an invalid compose_project value" + +postgres_seen=0 +redis_seen=0 +minio_seen=0 +checksum_rows=0 +while read -r digest filename extra; do + [[ -z "${extra:-}" ]] || fail "SHA256SUMS contains an invalid row" + [[ "${digest}" =~ ^[[:xdigit:]]{64}$ ]] || fail "SHA256SUMS contains an invalid digest" + filename="${filename#\*}" + case "${filename}" in + postgres.dump) postgres_seen=$((postgres_seen + 1)) ;; + redis-data.tar.gz) redis_seen=$((redis_seen + 1)) ;; + minio-data.tar.gz) minio_seen=$((minio_seen + 1)) ;; + *) fail "SHA256SUMS references an unexpected file: ${filename}" ;; + esac + checksum_rows=$((checksum_rows + 1)) +done < "${backup_dir}/SHA256SUMS" + +[[ ${checksum_rows} -eq 3 && ${postgres_seen} -eq 1 && ${redis_seen} -eq 1 && ${minio_seen} -eq 1 ]] \ + || fail "SHA256SUMS must reference each data artifact exactly once" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "${backup_dir}" && sha256sum -c SHA256SUMS) >/dev/null \ + || fail "artifact checksum mismatch" +else + (cd "${backup_dir}" && shasum -a 256 -c SHA256SUMS) >/dev/null \ + || fail "artifact checksum mismatch" +fi + +postgres_magic="$(LC_ALL=C head -c 5 "${backup_dir}/postgres.dump")" +[[ "${postgres_magic}" == "PGDMP" ]] || fail "PostgreSQL dump does not use the required custom format" + +verify_archive_paths() { + local archive_name="$1" + local listing_file + local entry + local normalized + listing_file="$(mktemp "${TMPDIR:-/tmp}/frameflow-archive-list.XXXXXX")" + if ! tar -tzf "${backup_dir}/${archive_name}" > "${listing_file}"; then + rm -f "${listing_file}" + fail "${archive_name} is not a readable gzip tar archive" + fi + if [[ ! -s "${listing_file}" ]]; then + rm -f "${listing_file}" + fail "${archive_name} contains no entries" + fi + while IFS= read -r entry; do + [[ "${entry}" != /* ]] || { + rm -f "${listing_file}" + fail "${archive_name} contains an absolute path" + } + normalized="${entry#./}" + case "/${normalized}/" in + *"/../"*) + rm -f "${listing_file}" + fail "${archive_name} contains a parent-directory path" + ;; + esac + done < "${listing_file}" + rm -f "${listing_file}" +} + +verify_archive_paths redis-data.tar.gz +verify_archive_paths minio-data.tar.gz + +echo "Backup verification passed: ${backup_dir}" +echo "Source Compose project: ${compose_project}; created at: ${created_at}" diff --git a/server/drizzle.config.ts b/server/drizzle.config.ts new file mode 100644 index 0000000..aa7ee13 --- /dev/null +++ b/server/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/db/schema.ts', + out: './drizzle', + dbCredentials: { + url: process.env.DATABASE_URL ?? 'postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow', + }, + strict: true, + verbose: true, +}) diff --git a/server/drizzle/0000_last_spectrum.sql b/server/drizzle/0000_last_spectrum.sql new file mode 100644 index 0000000..35d0e77 --- /dev/null +++ b/server/drizzle/0000_last_spectrum.sql @@ -0,0 +1,356 @@ +CREATE TYPE "public"."asset_status" AS ENUM('DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED');--> statement-breakpoint +CREATE TYPE "public"."asset_type" AS ENUM('CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC');--> statement-breakpoint +CREATE TYPE "public"."delivery_status" AS ENUM('DRAFT', 'QUEUED', 'PUBLISHING', 'PUBLISHED', 'FAILED');--> statement-breakpoint +CREATE TYPE "public"."episode_status" AS ENUM('OUTLINE', 'SCRIPTING', 'STORYBOARDING', 'PRODUCTION', 'REVIEW', 'PUBLISHED');--> statement-breakpoint +CREATE TYPE "public"."job_status" AS ENUM('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');--> statement-breakpoint +CREATE TYPE "public"."job_type" AS ENUM('SCRIPT_GENERATE', 'ASSET_GENERATE', 'TTS_GENERATE', 'VIDEO_GENERATE', 'LIPSYNC_GENERATE', 'EPISODE_RENDER', 'DELIVERY_PUBLISH');--> statement-breakpoint +CREATE TYPE "public"."membership_role" AS ENUM('OWNER', 'ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER');--> statement-breakpoint +CREATE TYPE "public"."project_status" AS ENUM('DRAFT', 'ACTIVE', 'ARCHIVED');--> statement-breakpoint +CREATE TYPE "public"."render_status" AS ENUM('QUEUED', 'RENDERING', 'REVIEW', 'APPROVED', 'FAILED');--> statement-breakpoint +CREATE TYPE "public"."review_status" AS ENUM('OPEN', 'APPROVED', 'CHANGES_REQUESTED', 'CANCELLED');--> statement-breakpoint +CREATE TYPE "public"."review_target_type" AS ENUM('SCRIPT_VERSION', 'ASSET_VERSION', 'SHOT_VERSION', 'RENDER');--> statement-breakpoint +CREATE TYPE "public"."shot_status" AS ENUM('DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'RENDERED');--> statement-breakpoint +CREATE TYPE "public"."usage_kind" AS ENUM('TEXT_INPUT_TOKEN', 'TEXT_OUTPUT_TOKEN', 'IMAGE', 'AUDIO_SECOND', 'VIDEO_SECOND', 'RENDER_SECOND', 'STORAGE_BYTE_MONTH');--> statement-breakpoint +CREATE TABLE "asset_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "asset_id" uuid NOT NULL, + "version" integer NOT NULL, + "storage_key" text, + "thumbnail_key" text, + "mime_type" varchar(120), + "byte_size" integer, + "width" integer, + "height" integer, + "duration_ms" integer, + "prompt" text DEFAULT '' NOT NULL, + "negative_prompt" text DEFAULT '' NOT NULL, + "provider" varchar(80), + "provider_model" varchar(160), + "seed" varchar(80), + "generation_params" jsonb DEFAULT '{}'::jsonb NOT NULL, + "is_current" boolean DEFAULT false NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "assets" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "type" "asset_type" NOT NULL, + "name" varchar(180) NOT NULL, + "description" text DEFAULT '' NOT NULL, + "status" "asset_status" DEFAULT 'DRAFT' NOT NULL, + "tags" jsonb DEFAULT '[]'::jsonb NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "audit_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid, + "actor_id" uuid, + "action" varchar(120) NOT NULL, + "resource_type" varchar(80) NOT NULL, + "resource_id" uuid, + "ip_address" varchar(64), + "user_agent" text, + "before" jsonb, + "after" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "render_id" uuid NOT NULL, + "platform" varchar(80) NOT NULL, + "status" "delivery_status" DEFAULT 'DRAFT' NOT NULL, + "destination" jsonb DEFAULT '{}'::jsonb NOT NULL, + "external_id" varchar(255), + "published_url" text, + "scheduled_at" timestamp with time zone, + "published_at" timestamp with time zone, + "created_by_id" uuid NOT NULL, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "episodes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "project_id" uuid NOT NULL, + "episode_number" integer NOT NULL, + "title" varchar(200) NOT NULL, + "synopsis" text DEFAULT '' NOT NULL, + "target_duration_seconds" integer DEFAULT 180 NOT NULL, + "status" "episode_status" DEFAULT 'OUTLINE' NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "generation_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "project_id" uuid, + "episode_id" uuid, + "created_by_id" uuid NOT NULL, + "type" "job_type" NOT NULL, + "status" "job_status" DEFAULT 'QUEUED' NOT NULL, + "progress" integer DEFAULT 0 NOT NULL, + "provider" varchar(80), + "provider_model" varchar(160), + "external_id" varchar(255), + "idempotency_key" varchar(180) NOT NULL, + "input" jsonb NOT NULL, + "output" jsonb, + "error_code" varchar(100), + "error_message" text, + "attempts" integer DEFAULT 0 NOT NULL, + "max_attempts" integer DEFAULT 3 NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "memberships" ( + "workspace_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "role" "membership_role" DEFAULT 'VIEWER' NOT NULL, + "invited_by_id" uuid, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "memberships_workspace_id_user_id_pk" PRIMARY KEY("workspace_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "created_by_id" uuid NOT NULL, + "name" varchar(180) NOT NULL, + "slug" varchar(100) NOT NULL, + "logline" text DEFAULT '' NOT NULL, + "genre" varchar(80) DEFAULT '都市悬疑' NOT NULL, + "visual_bible" jsonb DEFAULT '{}'::jsonb NOT NULL, + "status" "project_status" DEFAULT 'DRAFT' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "refresh_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "revoked_at" timestamp with time zone, + "replaced_by_id" uuid, + "user_agent" text, + "ip_address" varchar(64), + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "renders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "episode_id" uuid NOT NULL, + "version" integer NOT NULL, + "status" "render_status" DEFAULT 'QUEUED' NOT NULL, + "storage_key" text, + "thumbnail_key" text, + "mime_type" varchar(120) DEFAULT 'video/mp4' NOT NULL, + "byte_size" integer, + "duration_ms" integer, + "width" integer NOT NULL, + "height" integer NOT NULL, + "fps" integer DEFAULT 24 NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "quality_report" jsonb, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "review_comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "review_request_id" uuid NOT NULL, + "author_id" uuid NOT NULL, + "body" text NOT NULL, + "timecode_ms" integer, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "review_requests" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "target_type" "review_target_type" NOT NULL, + "target_id" uuid NOT NULL, + "status" "review_status" DEFAULT 'OPEN' NOT NULL, + "requested_by_id" uuid NOT NULL, + "assigned_to_id" uuid, + "resolved_by_id" uuid, + "summary" text DEFAULT '' NOT NULL, + "resolved_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "script_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "episode_id" uuid NOT NULL, + "version" integer NOT NULL, + "title" varchar(200) NOT NULL, + "content" jsonb NOT NULL, + "plain_text" text DEFAULT '' NOT NULL, + "change_summary" text DEFAULT '' NOT NULL, + "is_current" boolean DEFAULT false NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shot_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "shot_id" uuid NOT NULL, + "version" integer NOT NULL, + "image_asset_version_id" uuid, + "video_storage_key" text, + "audio_storage_key" text, + "subtitle_text" text DEFAULT '' NOT NULL, + "generation_params" jsonb DEFAULT '{}'::jsonb NOT NULL, + "is_current" boolean DEFAULT false NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shots" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "episode_id" uuid NOT NULL, + "shot_number" integer NOT NULL, + "scene_number" integer NOT NULL, + "title" varchar(180) NOT NULL, + "description" text DEFAULT '' NOT NULL, + "shot_type" varchar(50) DEFAULT '中景' NOT NULL, + "camera_motion" varchar(50) DEFAULT '静止' NOT NULL, + "duration_ms" integer DEFAULT 3000 NOT NULL, + "status" "shot_status" DEFAULT 'DRAFT' NOT NULL, + "character_asset_ids" jsonb DEFAULT '[]'::jsonb NOT NULL, + "scene_asset_id" uuid, + "sort_order" integer NOT NULL, + "created_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "usage_ledger" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" uuid, + "job_id" uuid, + "kind" "usage_kind" NOT NULL, + "quantity" numeric(20, 4) NOT NULL, + "credits" numeric(20, 4) NOT NULL, + "provider_cost_usd" numeric(20, 6), + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "occurred_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" varchar(320) NOT NULL, + "display_name" varchar(120) NOT NULL, + "password_hash" text NOT NULL, + "avatar_key" text, + "email_verified_at" timestamp with time zone, + "disabled_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workspaces" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" varchar(160) NOT NULL, + "slug" varchar(80) NOT NULL, + "owner_id" uuid NOT NULL, + "plan" varchar(32) DEFAULT 'PRO' NOT NULL, + "monthly_credit_limit" integer DEFAULT 10000 NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "asset_versions" ADD CONSTRAINT "asset_versions_asset_id_assets_id_fk" FOREIGN KEY ("asset_id") REFERENCES "public"."assets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "asset_versions" ADD CONSTRAINT "asset_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "assets" ADD CONSTRAINT "assets_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "assets" ADD CONSTRAINT "assets_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_render_id_renders_id_fk" FOREIGN KEY ("render_id") REFERENCES "public"."renders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "episodes" ADD CONSTRAINT "episodes_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "episodes" ADD CONSTRAINT "episodes_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_invited_by_id_users_id_fk" FOREIGN KEY ("invited_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "projects" ADD CONSTRAINT "projects_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "renders" ADD CONSTRAINT "renders_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "renders" ADD CONSTRAINT "renders_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_comments" ADD CONSTRAINT "review_comments_review_request_id_review_requests_id_fk" FOREIGN KEY ("review_request_id") REFERENCES "public"."review_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_comments" ADD CONSTRAINT "review_comments_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_requested_by_id_users_id_fk" FOREIGN KEY ("requested_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_assigned_to_id_users_id_fk" FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "review_requests" ADD CONSTRAINT "review_requests_resolved_by_id_users_id_fk" FOREIGN KEY ("resolved_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "script_versions" ADD CONSTRAINT "script_versions_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "script_versions" ADD CONSTRAINT "script_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_shot_id_shots_id_fk" FOREIGN KEY ("shot_id") REFERENCES "public"."shots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_image_asset_version_id_asset_versions_id_fk" FOREIGN KEY ("image_asset_version_id") REFERENCES "public"."asset_versions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shot_versions" ADD CONSTRAINT "shot_versions_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shots" ADD CONSTRAINT "shots_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shots" ADD CONSTRAINT "shots_scene_asset_id_assets_id_fk" FOREIGN KEY ("scene_asset_id") REFERENCES "public"."assets"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shots" ADD CONSTRAINT "shots_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "usage_ledger" ADD CONSTRAINT "usage_ledger_job_id_generation_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."generation_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "asset_versions_asset_version_unique" ON "asset_versions" USING btree ("asset_id","version");--> statement-breakpoint +CREATE INDEX "asset_versions_current_idx" ON "asset_versions" USING btree ("asset_id","is_current");--> statement-breakpoint +CREATE INDEX "assets_project_type_idx" ON "assets" USING btree ("project_id","type");--> statement-breakpoint +CREATE INDEX "assets_project_status_idx" ON "assets" USING btree ("project_id","status");--> statement-breakpoint +CREATE INDEX "audit_logs_workspace_time_idx" ON "audit_logs" USING btree ("workspace_id","created_at");--> statement-breakpoint +CREATE INDEX "audit_logs_actor_idx" ON "audit_logs" USING btree ("actor_id");--> statement-breakpoint +CREATE INDEX "deliveries_render_idx" ON "deliveries" USING btree ("render_id");--> statement-breakpoint +CREATE INDEX "deliveries_status_idx" ON "deliveries" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "episodes_project_number_unique" ON "episodes" USING btree ("project_id","episode_number");--> statement-breakpoint +CREATE INDEX "episodes_project_idx" ON "episodes" USING btree ("project_id");--> statement-breakpoint +CREATE UNIQUE INDEX "generation_jobs_idempotency_unique" ON "generation_jobs" USING btree ("workspace_id","idempotency_key");--> statement-breakpoint +CREATE INDEX "generation_jobs_workspace_status_idx" ON "generation_jobs" USING btree ("workspace_id","status");--> statement-breakpoint +CREATE INDEX "generation_jobs_project_idx" ON "generation_jobs" USING btree ("project_id");--> statement-breakpoint +CREATE INDEX "memberships_user_idx" ON "memberships" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "projects_workspace_slug_unique" ON "projects" USING btree ("workspace_id","slug");--> statement-breakpoint +CREATE INDEX "projects_workspace_idx" ON "projects" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "refresh_tokens_hash_unique" ON "refresh_tokens" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "refresh_tokens_user_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "renders_episode_version_unique" ON "renders" USING btree ("episode_id","version");--> statement-breakpoint +CREATE INDEX "renders_episode_status_idx" ON "renders" USING btree ("episode_id","status");--> statement-breakpoint +CREATE INDEX "review_comments_request_idx" ON "review_comments" USING btree ("review_request_id");--> statement-breakpoint +CREATE INDEX "review_requests_workspace_status_idx" ON "review_requests" USING btree ("workspace_id","status");--> statement-breakpoint +CREATE INDEX "review_requests_target_idx" ON "review_requests" USING btree ("target_type","target_id");--> statement-breakpoint +CREATE UNIQUE INDEX "script_versions_episode_version_unique" ON "script_versions" USING btree ("episode_id","version");--> statement-breakpoint +CREATE INDEX "script_versions_episode_current_idx" ON "script_versions" USING btree ("episode_id","is_current");--> statement-breakpoint +CREATE UNIQUE INDEX "shot_versions_shot_version_unique" ON "shot_versions" USING btree ("shot_id","version");--> statement-breakpoint +CREATE INDEX "shot_versions_current_idx" ON "shot_versions" USING btree ("shot_id","is_current");--> statement-breakpoint +CREATE UNIQUE INDEX "shots_episode_number_unique" ON "shots" USING btree ("episode_id","shot_number");--> statement-breakpoint +CREATE INDEX "shots_episode_sort_idx" ON "shots" USING btree ("episode_id","sort_order");--> statement-breakpoint +CREATE INDEX "usage_ledger_workspace_time_idx" ON "usage_ledger" USING btree ("workspace_id","occurred_at");--> statement-breakpoint +CREATE INDEX "usage_ledger_job_idx" ON "usage_ledger" USING btree ("job_id");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_unique" ON "users" USING btree ("email");--> statement-breakpoint +CREATE UNIQUE INDEX "workspaces_slug_unique" ON "workspaces" USING btree ("slug");--> statement-breakpoint +CREATE INDEX "workspaces_owner_idx" ON "workspaces" USING btree ("owner_id"); \ No newline at end of file diff --git a/server/drizzle/0001_perfect_gravity.sql b/server/drizzle/0001_perfect_gravity.sql new file mode 100644 index 0000000..0ec0b02 --- /dev/null +++ b/server/drizzle/0001_perfect_gravity.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."job_type" ADD VALUE 'PIPELINE_HEALTHCHECK' BEFORE 'SCRIPT_GENERATE'; \ No newline at end of file diff --git a/server/drizzle/0002_yielding_spencer_smythe.sql b/server/drizzle/0002_yielding_spencer_smythe.sql new file mode 100644 index 0000000..d585dfe --- /dev/null +++ b/server/drizzle/0002_yielding_spencer_smythe.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."asset_type" ADD VALUE 'SHOT'; \ No newline at end of file diff --git a/server/drizzle/0003_hard_norrin_radd.sql b/server/drizzle/0003_hard_norrin_radd.sql new file mode 100644 index 0000000..d311941 --- /dev/null +++ b/server/drizzle/0003_hard_norrin_radd.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."job_type" ADD VALUE 'STORYBOARD_GENERATE' BEFORE 'ASSET_GENERATE'; \ No newline at end of file diff --git a/server/drizzle/0004_goofy_karnak.sql b/server/drizzle/0004_goofy_karnak.sql new file mode 100644 index 0000000..b15fd92 --- /dev/null +++ b/server/drizzle/0004_goofy_karnak.sql @@ -0,0 +1 @@ +ALTER TABLE "generation_jobs" ADD COLUMN "estimated_credits" numeric(20, 4) DEFAULT '0' NOT NULL; \ No newline at end of file diff --git a/server/drizzle/0005_brave_betty_ross.sql b/server/drizzle/0005_brave_betty_ross.sql new file mode 100644 index 0000000..ed7ddf5 --- /dev/null +++ b/server/drizzle/0005_brave_betty_ross.sql @@ -0,0 +1,21 @@ +CREATE TABLE "workspace_invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "email" varchar(320) NOT NULL, + "role" "membership_role" DEFAULT 'VIEWER' NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "accepted_at" timestamp with time zone, + "accepted_by_id" uuid, + "revoked_at" timestamp with time zone, + "invited_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_accepted_by_id_users_id_fk" FOREIGN KEY ("accepted_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_invited_by_id_users_id_fk" FOREIGN KEY ("invited_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "workspace_invitations_token_hash_unique" ON "workspace_invitations" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "workspace_invitations_workspace_email_idx" ON "workspace_invitations" USING btree ("workspace_id","email");--> statement-breakpoint +CREATE INDEX "workspace_invitations_workspace_created_idx" ON "workspace_invitations" USING btree ("workspace_id","created_at"); \ No newline at end of file diff --git a/server/drizzle/0006_wooden_texas_twister.sql b/server/drizzle/0006_wooden_texas_twister.sql new file mode 100644 index 0000000..5d9b2db --- /dev/null +++ b/server/drizzle/0006_wooden_texas_twister.sql @@ -0,0 +1,24 @@ +CREATE TYPE "public"."production_run_status" AS ENUM('QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED');--> statement-breakpoint +CREATE TABLE "production_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "episode_id" uuid NOT NULL, + "render_id" uuid NOT NULL, + "status" "production_run_status" DEFAULT 'QUEUED' NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "total_jobs" integer NOT NULL, + "created_by_id" uuid NOT NULL, + "started_at" timestamp with time zone, + "completed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD COLUMN "production_run_id" uuid;--> statement-breakpoint +ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_episode_id_episodes_id_fk" FOREIGN KEY ("episode_id") REFERENCES "public"."episodes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_render_id_renders_id_fk" FOREIGN KEY ("render_id") REFERENCES "public"."renders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "production_runs" ADD CONSTRAINT "production_runs_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "production_runs_render_unique" ON "production_runs" USING btree ("render_id");--> statement-breakpoint +CREATE INDEX "production_runs_episode_status_idx" ON "production_runs" USING btree ("episode_id","status");--> statement-breakpoint +CREATE INDEX "production_runs_episode_created_idx" ON "production_runs" USING btree ("episode_id","created_at");--> statement-breakpoint +ALTER TABLE "generation_jobs" ADD CONSTRAINT "generation_jobs_production_run_id_production_runs_id_fk" FOREIGN KEY ("production_run_id") REFERENCES "public"."production_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "generation_jobs_production_run_idx" ON "generation_jobs" USING btree ("production_run_id"); \ No newline at end of file diff --git a/server/drizzle/0007_sudden_roland_deschain.sql b/server/drizzle/0007_sudden_roland_deschain.sql new file mode 100644 index 0000000..8adc300 --- /dev/null +++ b/server/drizzle/0007_sudden_roland_deschain.sql @@ -0,0 +1,15 @@ +CREATE TYPE "public"."account_token_type" AS ENUM('EMAIL_VERIFICATION', 'PASSWORD_RESET');--> statement-breakpoint +CREATE TABLE "account_action_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" "account_token_type" NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account_action_tokens" ADD CONSTRAINT "account_action_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "account_action_tokens_hash_unique" ON "account_action_tokens" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "account_action_tokens_user_type_idx" ON "account_action_tokens" USING btree ("user_id","type");--> statement-breakpoint +CREATE INDEX "account_action_tokens_expiry_idx" ON "account_action_tokens" USING btree ("expires_at"); \ No newline at end of file diff --git a/server/drizzle/0008_red_trish_tilby.sql b/server/drizzle/0008_red_trish_tilby.sql new file mode 100644 index 0000000..a73f3f0 --- /dev/null +++ b/server/drizzle/0008_red_trish_tilby.sql @@ -0,0 +1,4 @@ +ALTER TYPE "public"."delivery_status" ADD VALUE 'CANCELLED';--> statement-breakpoint +ALTER TABLE "deliveries" ADD COLUMN "job_id" uuid;--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_job_id_generation_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."generation_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "deliveries_job_idx" ON "deliveries" USING btree ("job_id"); \ No newline at end of file diff --git a/server/drizzle/0009_chilly_mockingbird.sql b/server/drizzle/0009_chilly_mockingbird.sql new file mode 100644 index 0000000..380cb8c --- /dev/null +++ b/server/drizzle/0009_chilly_mockingbird.sql @@ -0,0 +1,34 @@ +CREATE TABLE "billing_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "provider" varchar(32) DEFAULT 'stripe' NOT NULL, + "customer_id" varchar(255) NOT NULL, + "subscription_id" varchar(255), + "price_id" varchar(255), + "plan" varchar(32), + "status" varchar(48) DEFAULT 'no_subscription' NOT NULL, + "current_period_end" timestamp with time zone, + "cancel_at_period_end" boolean DEFAULT false NOT NULL, + "last_event_created_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "billing_webhook_events" ( + "event_id" varchar(255) PRIMARY KEY NOT NULL, + "provider" varchar(32) DEFAULT 'stripe' NOT NULL, + "event_type" varchar(160) NOT NULL, + "livemode" boolean DEFAULT false NOT NULL, + "workspace_id" uuid, + "provider_created_at" timestamp with time zone NOT NULL, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "billing_subscriptions" ADD CONSTRAINT "billing_subscriptions_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "billing_webhook_events" ADD CONSTRAINT "billing_webhook_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "billing_subscriptions_workspace_unique" ON "billing_subscriptions" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "billing_subscriptions_customer_unique" ON "billing_subscriptions" USING btree ("customer_id");--> statement-breakpoint +CREATE UNIQUE INDEX "billing_subscriptions_subscription_unique" ON "billing_subscriptions" USING btree ("subscription_id");--> statement-breakpoint +CREATE INDEX "billing_subscriptions_status_idx" ON "billing_subscriptions" USING btree ("status");--> statement-breakpoint +CREATE INDEX "billing_webhook_events_workspace_idx" ON "billing_webhook_events" USING btree ("workspace_id","processed_at");--> statement-breakpoint +CREATE INDEX "billing_webhook_events_type_idx" ON "billing_webhook_events" USING btree ("event_type"); \ No newline at end of file diff --git a/server/drizzle/0010_sharp_odin.sql b/server/drizzle/0010_sharp_odin.sql new file mode 100644 index 0000000..3d71edc --- /dev/null +++ b/server/drizzle/0010_sharp_odin.sql @@ -0,0 +1,3 @@ +ALTER TABLE "renders" ADD COLUMN "subtitle_srt_key" text;--> statement-breakpoint +ALTER TABLE "renders" ADD COLUMN "subtitle_vtt_key" text;--> statement-breakpoint +ALTER TABLE "renders" ADD COLUMN "source_manifest" jsonb; \ No newline at end of file diff --git a/server/drizzle/0011_futuristic_stature.sql b/server/drizzle/0011_futuristic_stature.sql new file mode 100644 index 0000000..9df427b --- /dev/null +++ b/server/drizzle/0011_futuristic_stature.sql @@ -0,0 +1,25 @@ +CREATE TYPE "public"."notification_tone" AS ENUM('INFO', 'SUCCESS', 'WARNING', 'ERROR');--> statement-breakpoint +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "kind" varchar(80) NOT NULL, + "tone" "notification_tone" DEFAULT 'INFO' NOT NULL, + "title" varchar(240) NOT NULL, + "body" text DEFAULT '' NOT NULL, + "page" varchar(48) NOT NULL, + "resource_type" varchar(80), + "resource_id" uuid, + "dedupe_key" varchar(255) NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "read_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "notifications_user_dedupe_unique" ON "notifications" USING btree ("user_id","dedupe_key");--> statement-breakpoint +CREATE INDEX "notifications_user_created_idx" ON "notifications" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "notifications_user_unread_idx" ON "notifications" USING btree ("user_id","read_at","created_at");--> statement-breakpoint +CREATE INDEX "notifications_workspace_created_idx" ON "notifications" USING btree ("workspace_id","created_at"); \ No newline at end of file diff --git a/server/drizzle/0012_warm_old_lace.sql b/server/drizzle/0012_warm_old_lace.sql new file mode 100644 index 0000000..c6da466 --- /dev/null +++ b/server/drizzle/0012_warm_old_lace.sql @@ -0,0 +1,62 @@ +CREATE TYPE "public"."publishing_channel_status" AS ENUM('CONNECTED', 'EXPIRED', 'REVOKED', 'ERROR');--> statement-breakpoint +CREATE TABLE "publishing_channel_accounts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "platform" varchar(32) NOT NULL, + "external_account_id" varchar(255) NOT NULL, + "display_name" varchar(180) NOT NULL, + "avatar_url" text, + "status" "publishing_channel_status" DEFAULT 'CONNECTED' NOT NULL, + "access_token_ciphertext" text, + "refresh_token_ciphertext" text, + "token_expires_at" timestamp with time zone, + "scopes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL, + "connected_by_id" uuid NOT NULL, + "last_verified_at" timestamp with time zone DEFAULT now() NOT NULL, + "error_code" varchar(100), + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "publishing_oauth_states" ( + "state_hash" varchar(64) PRIMARY KEY NOT NULL, + "workspace_id" uuid NOT NULL, + "platform" varchar(32) NOT NULL, + "code_verifier_ciphertext" text, + "return_path" text DEFAULT '/?publishing=connected' NOT NULL, + "created_by_id" uuid NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "consumed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "publishing_webhook_events" ( + "event_key" varchar(320) PRIMARY KEY NOT NULL, + "event_id" varchar(255) NOT NULL, + "platform" varchar(32) NOT NULL, + "delivery_id" uuid, + "status" varchar(32) NOT NULL, + "provider_occurred_at" timestamp with time zone NOT NULL, + "payload" jsonb DEFAULT '{}'::jsonb NOT NULL, + "applied" boolean DEFAULT false NOT NULL, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "deliveries" ADD COLUMN "channel_account_id" uuid;--> statement-breakpoint +ALTER TABLE "deliveries" ADD COLUMN "last_provider_event_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "publishing_channel_accounts" ADD CONSTRAINT "publishing_channel_accounts_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "publishing_channel_accounts" ADD CONSTRAINT "publishing_channel_accounts_connected_by_id_users_id_fk" FOREIGN KEY ("connected_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "publishing_oauth_states" ADD CONSTRAINT "publishing_oauth_states_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "publishing_oauth_states" ADD CONSTRAINT "publishing_oauth_states_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "publishing_webhook_events" ADD CONSTRAINT "publishing_webhook_events_delivery_id_deliveries_id_fk" FOREIGN KEY ("delivery_id") REFERENCES "public"."deliveries"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "publishing_channel_accounts_external_unique" ON "publishing_channel_accounts" USING btree ("workspace_id","platform","external_account_id");--> statement-breakpoint +CREATE INDEX "publishing_channel_accounts_workspace_idx" ON "publishing_channel_accounts" USING btree ("workspace_id","platform");--> statement-breakpoint +CREATE INDEX "publishing_channel_accounts_status_idx" ON "publishing_channel_accounts" USING btree ("workspace_id","status");--> statement-breakpoint +CREATE INDEX "publishing_oauth_states_expiry_idx" ON "publishing_oauth_states" USING btree ("expires_at");--> statement-breakpoint +CREATE INDEX "publishing_oauth_states_workspace_idx" ON "publishing_oauth_states" USING btree ("workspace_id","platform");--> statement-breakpoint +CREATE INDEX "publishing_webhook_events_delivery_idx" ON "publishing_webhook_events" USING btree ("delivery_id","provider_occurred_at");--> statement-breakpoint +CREATE INDEX "publishing_webhook_events_platform_idx" ON "publishing_webhook_events" USING btree ("platform","processed_at");--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "deliveries_channel_account_idx" ON "deliveries" USING btree ("channel_account_id"); \ No newline at end of file diff --git a/server/drizzle/0013_lethal_giant_man.sql b/server/drizzle/0013_lethal_giant_man.sql new file mode 100644 index 0000000..133b584 --- /dev/null +++ b/server/drizzle/0013_lethal_giant_man.sql @@ -0,0 +1,3 @@ +ALTER TABLE "publishing_webhook_events" ADD COLUMN "workspace_id" uuid NOT NULL;--> statement-breakpoint +ALTER TABLE "publishing_webhook_events" ADD CONSTRAINT "publishing_webhook_events_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "publishing_webhook_events_workspace_idx" ON "publishing_webhook_events" USING btree ("workspace_id","processed_at"); \ No newline at end of file diff --git a/server/drizzle/0014_heavy_retro_girl.sql b/server/drizzle/0014_heavy_retro_girl.sql new file mode 100644 index 0000000..5e26acf --- /dev/null +++ b/server/drizzle/0014_heavy_retro_girl.sql @@ -0,0 +1,3 @@ +ALTER TABLE "deliveries" DROP CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk"; +--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/server/drizzle/0015_reflective_true_believers.sql b/server/drizzle/0015_reflective_true_believers.sql new file mode 100644 index 0000000..e195b41 --- /dev/null +++ b/server/drizzle/0015_reflective_true_believers.sql @@ -0,0 +1,3 @@ +ALTER TABLE "deliveries" DROP CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk"; +--> statement-breakpoint +ALTER TABLE "deliveries" ADD CONSTRAINT "deliveries_channel_account_id_publishing_channel_accounts_id_fk" FOREIGN KEY ("channel_account_id") REFERENCES "public"."publishing_channel_accounts"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/server/drizzle/0016_amused_longshot.sql b/server/drizzle/0016_amused_longshot.sql new file mode 100644 index 0000000..04879a5 --- /dev/null +++ b/server/drizzle/0016_amused_longshot.sql @@ -0,0 +1,20 @@ +CREATE TABLE "provider_verifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "workspace_id" uuid NOT NULL, + "provider" varchar(32) NOT NULL, + "status" varchar(32) NOT NULL, + "configuration_fingerprint" varchar(64) NOT NULL, + "checked_at" timestamp with time zone DEFAULT now() NOT NULL, + "latency_ms" integer, + "error_code" varchar(80), + "message" text, + "details" jsonb DEFAULT '{}'::jsonb NOT NULL, + "checked_by_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "provider_verifications" ADD CONSTRAINT "provider_verifications_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "provider_verifications" ADD CONSTRAINT "provider_verifications_checked_by_id_users_id_fk" FOREIGN KEY ("checked_by_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "provider_verifications_workspace_provider_unique" ON "provider_verifications" USING btree ("workspace_id","provider");--> statement-breakpoint +CREATE INDEX "provider_verifications_workspace_status_idx" ON "provider_verifications" USING btree ("workspace_id","status"); \ No newline at end of file diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..3e677bb --- /dev/null +++ b/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,2980 @@ +{ + "id": "34864dff-ea51-4989-8dc8-3c2e81bd63d5", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "SCRIPT_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..6fc0280 --- /dev/null +++ b/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2981 @@ +{ + "id": "38b0fb89-3922-45b7-83ba-81248aff9685", + "prevId": "34864dff-ea51-4989-8dc8-3c2e81bd63d5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..6121a21 --- /dev/null +++ b/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,2982 @@ +{ + "id": "acdb9359-8b09-489e-a027-f62abffe093b", + "prevId": "38b0fb89-3922-45b7-83ba-81248aff9685", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..5a90cae --- /dev/null +++ b/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,2983 @@ +{ + "id": "d6396dbf-e5cd-4ca9-a622-8f47ad9a2ce6", + "prevId": "acdb9359-8b09-489e-a027-f62abffe093b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0004_snapshot.json b/server/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..7f06e80 --- /dev/null +++ b/server/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2990 @@ +{ + "id": "8bf065f5-954e-4821-8a01-82be442dc65d", + "prevId": "d6396dbf-e5cd-4ca9-a622-8f47ad9a2ce6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0005_snapshot.json b/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..aa700da --- /dev/null +++ b/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,3178 @@ +{ + "id": "05cc30f0-a2df-411d-8baf-b0243d5f748f", + "prevId": "8bf065f5-954e-4821-8a01-82be442dc65d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0006_snapshot.json b/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..cc2e216 --- /dev/null +++ b/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,3406 @@ +{ + "id": "166980e0-15f9-4539-bbcc-0769218de091", + "prevId": "05cc30f0-a2df-411d-8baf-b0243d5f748f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0007_snapshot.json b/server/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..f05a050 --- /dev/null +++ b/server/drizzle/meta/0007_snapshot.json @@ -0,0 +1,3538 @@ +{ + "id": "8265f41f-cc78-4cad-82c9-82721ba18ccc", + "prevId": "166980e0-15f9-4539-bbcc-0769218de091", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0008_snapshot.json b/server/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..7baeb28 --- /dev/null +++ b/server/drizzle/meta/0008_snapshot.json @@ -0,0 +1,3573 @@ +{ + "id": "21f5a9cd-de25-45d1-8620-3d133d3d161d", + "prevId": "8265f41f-cc78-4cad-82c9-82721ba18ccc", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0009_snapshot.json b/server/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..0819759 --- /dev/null +++ b/server/drizzle/meta/0009_snapshot.json @@ -0,0 +1,3854 @@ +{ + "id": "afc084f4-1d7f-4c41-b31c-d6a3d738ef18", + "prevId": "21f5a9cd-de25-45d1-8620-3d133d3d161d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0010_snapshot.json b/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..66bc5cb --- /dev/null +++ b/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,3872 @@ +{ + "id": "b059ffe6-7413-4008-ac83-b763457d5aa1", + "prevId": "afc084f4-1d7f-4c41-b31c-d6a3d738ef18", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0011_snapshot.json b/server/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..ca72894 --- /dev/null +++ b/server/drizzle/meta/0011_snapshot.json @@ -0,0 +1,4110 @@ +{ + "id": "72ba7485-1bb1-4a18-b819-e7099b57544c", + "prevId": "b059ffe6-7413-4008-ac83-b763457d5aa1", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0012_snapshot.json b/server/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..01c4a48 --- /dev/null +++ b/server/drizzle/meta/0012_snapshot.json @@ -0,0 +1,4646 @@ +{ + "id": "3232a581-0f8c-466e-b51e-c808930a54b2", + "prevId": "72ba7485-1bb1-4a18-b819-e7099b57544c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_channel_account_idx": { + "name": "deliveries_channel_account_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_channel_account_id_publishing_channel_accounts_id_fk": { + "name": "deliveries_channel_account_id_publishing_channel_accounts_id_fk", + "tableFrom": "deliveries", + "tableTo": "publishing_channel_accounts", + "columnsFrom": [ + "channel_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_channel_accounts": { + "name": "publishing_channel_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "publishing_channel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTED'" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_channel_accounts_external_unique": { + "name": "publishing_channel_accounts_external_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_workspace_idx": { + "name": "publishing_channel_accounts_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_status_idx": { + "name": "publishing_channel_accounts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_channel_accounts_workspace_id_workspaces_id_fk": { + "name": "publishing_channel_accounts_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_channel_accounts_connected_by_id_users_id_fk": { + "name": "publishing_channel_accounts_connected_by_id_users_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_oauth_states": { + "name": "publishing_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "code_verifier_ciphertext": { + "name": "code_verifier_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/?publishing=connected'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_oauth_states_expiry_idx": { + "name": "publishing_oauth_states_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_oauth_states_workspace_idx": { + "name": "publishing_oauth_states_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_oauth_states_workspace_id_workspaces_id_fk": { + "name": "publishing_oauth_states_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_oauth_states_created_by_id_users_id_fk": { + "name": "publishing_oauth_states_created_by_id_users_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_webhook_events": { + "name": "publishing_webhook_events", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "varchar(320)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_webhook_events_delivery_idx": { + "name": "publishing_webhook_events_delivery_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_platform_idx": { + "name": "publishing_webhook_events_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_webhook_events_delivery_id_deliveries_id_fk": { + "name": "publishing_webhook_events_delivery_id_deliveries_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "deliveries", + "columnsFrom": [ + "delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.publishing_channel_status": { + "name": "publishing_channel_status", + "schema": "public", + "values": [ + "CONNECTED", + "EXPIRED", + "REVOKED", + "ERROR" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0013_snapshot.json b/server/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..120c747 --- /dev/null +++ b/server/drizzle/meta/0013_snapshot.json @@ -0,0 +1,4686 @@ +{ + "id": "9556a6ed-469f-4031-9275-7fb836907b5d", + "prevId": "3232a581-0f8c-466e-b51e-c808930a54b2", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_channel_account_idx": { + "name": "deliveries_channel_account_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_channel_account_id_publishing_channel_accounts_id_fk": { + "name": "deliveries_channel_account_id_publishing_channel_accounts_id_fk", + "tableFrom": "deliveries", + "tableTo": "publishing_channel_accounts", + "columnsFrom": [ + "channel_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_channel_accounts": { + "name": "publishing_channel_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "publishing_channel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTED'" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_channel_accounts_external_unique": { + "name": "publishing_channel_accounts_external_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_workspace_idx": { + "name": "publishing_channel_accounts_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_status_idx": { + "name": "publishing_channel_accounts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_channel_accounts_workspace_id_workspaces_id_fk": { + "name": "publishing_channel_accounts_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_channel_accounts_connected_by_id_users_id_fk": { + "name": "publishing_channel_accounts_connected_by_id_users_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_oauth_states": { + "name": "publishing_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "code_verifier_ciphertext": { + "name": "code_verifier_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/?publishing=connected'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_oauth_states_expiry_idx": { + "name": "publishing_oauth_states_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_oauth_states_workspace_idx": { + "name": "publishing_oauth_states_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_oauth_states_workspace_id_workspaces_id_fk": { + "name": "publishing_oauth_states_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_oauth_states_created_by_id_users_id_fk": { + "name": "publishing_oauth_states_created_by_id_users_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_webhook_events": { + "name": "publishing_webhook_events", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "varchar(320)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_webhook_events_delivery_idx": { + "name": "publishing_webhook_events_delivery_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_workspace_idx": { + "name": "publishing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_platform_idx": { + "name": "publishing_webhook_events_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "publishing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_webhook_events_delivery_id_deliveries_id_fk": { + "name": "publishing_webhook_events_delivery_id_deliveries_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "deliveries", + "columnsFrom": [ + "delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.publishing_channel_status": { + "name": "publishing_channel_status", + "schema": "public", + "values": [ + "CONNECTED", + "EXPIRED", + "REVOKED", + "ERROR" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0014_snapshot.json b/server/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..a9a5bf8 --- /dev/null +++ b/server/drizzle/meta/0014_snapshot.json @@ -0,0 +1,4686 @@ +{ + "id": "6af179a4-1050-424f-b129-91a2300b9544", + "prevId": "9556a6ed-469f-4031-9275-7fb836907b5d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_channel_account_idx": { + "name": "deliveries_channel_account_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_channel_account_id_publishing_channel_accounts_id_fk": { + "name": "deliveries_channel_account_id_publishing_channel_accounts_id_fk", + "tableFrom": "deliveries", + "tableTo": "publishing_channel_accounts", + "columnsFrom": [ + "channel_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_channel_accounts": { + "name": "publishing_channel_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "publishing_channel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTED'" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_channel_accounts_external_unique": { + "name": "publishing_channel_accounts_external_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_workspace_idx": { + "name": "publishing_channel_accounts_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_status_idx": { + "name": "publishing_channel_accounts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_channel_accounts_workspace_id_workspaces_id_fk": { + "name": "publishing_channel_accounts_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_channel_accounts_connected_by_id_users_id_fk": { + "name": "publishing_channel_accounts_connected_by_id_users_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_oauth_states": { + "name": "publishing_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "code_verifier_ciphertext": { + "name": "code_verifier_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/?publishing=connected'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_oauth_states_expiry_idx": { + "name": "publishing_oauth_states_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_oauth_states_workspace_idx": { + "name": "publishing_oauth_states_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_oauth_states_workspace_id_workspaces_id_fk": { + "name": "publishing_oauth_states_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_oauth_states_created_by_id_users_id_fk": { + "name": "publishing_oauth_states_created_by_id_users_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_webhook_events": { + "name": "publishing_webhook_events", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "varchar(320)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_webhook_events_delivery_idx": { + "name": "publishing_webhook_events_delivery_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_workspace_idx": { + "name": "publishing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_platform_idx": { + "name": "publishing_webhook_events_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "publishing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_webhook_events_delivery_id_deliveries_id_fk": { + "name": "publishing_webhook_events_delivery_id_deliveries_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "deliveries", + "columnsFrom": [ + "delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.publishing_channel_status": { + "name": "publishing_channel_status", + "schema": "public", + "values": [ + "CONNECTED", + "EXPIRED", + "REVOKED", + "ERROR" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0015_snapshot.json b/server/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..08815cf --- /dev/null +++ b/server/drizzle/meta/0015_snapshot.json @@ -0,0 +1,4686 @@ +{ + "id": "69e62589-0f6f-4604-830b-0cd44e776e43", + "prevId": "6af179a4-1050-424f-b129-91a2300b9544", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_channel_account_idx": { + "name": "deliveries_channel_account_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_channel_account_id_publishing_channel_accounts_id_fk": { + "name": "deliveries_channel_account_id_publishing_channel_accounts_id_fk", + "tableFrom": "deliveries", + "tableTo": "publishing_channel_accounts", + "columnsFrom": [ + "channel_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_channel_accounts": { + "name": "publishing_channel_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "publishing_channel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTED'" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_channel_accounts_external_unique": { + "name": "publishing_channel_accounts_external_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_workspace_idx": { + "name": "publishing_channel_accounts_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_status_idx": { + "name": "publishing_channel_accounts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_channel_accounts_workspace_id_workspaces_id_fk": { + "name": "publishing_channel_accounts_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_channel_accounts_connected_by_id_users_id_fk": { + "name": "publishing_channel_accounts_connected_by_id_users_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_oauth_states": { + "name": "publishing_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "code_verifier_ciphertext": { + "name": "code_verifier_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/?publishing=connected'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_oauth_states_expiry_idx": { + "name": "publishing_oauth_states_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_oauth_states_workspace_idx": { + "name": "publishing_oauth_states_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_oauth_states_workspace_id_workspaces_id_fk": { + "name": "publishing_oauth_states_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_oauth_states_created_by_id_users_id_fk": { + "name": "publishing_oauth_states_created_by_id_users_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_webhook_events": { + "name": "publishing_webhook_events", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "varchar(320)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_webhook_events_delivery_idx": { + "name": "publishing_webhook_events_delivery_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_workspace_idx": { + "name": "publishing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_platform_idx": { + "name": "publishing_webhook_events_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "publishing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_webhook_events_delivery_id_deliveries_id_fk": { + "name": "publishing_webhook_events_delivery_id_deliveries_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "deliveries", + "columnsFrom": [ + "delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.publishing_channel_status": { + "name": "publishing_channel_status", + "schema": "public", + "values": [ + "CONNECTED", + "EXPIRED", + "REVOKED", + "ERROR" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0016_snapshot.json b/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..e014a55 --- /dev/null +++ b/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,4852 @@ +{ + "id": "991595be-497b-4ac6-ae8d-c93f4508b9fb", + "prevId": "69e62589-0f6f-4604-830b-0cd44e776e43", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account_action_tokens": { + "name": "account_action_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "account_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_action_tokens_hash_unique": { + "name": "account_action_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_user_type_idx": { + "name": "account_action_tokens_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_action_tokens_expiry_idx": { + "name": "account_action_tokens_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_action_tokens_user_id_users_id_fk": { + "name": "account_action_tokens_user_id_users_id_fk", + "tableFrom": "account_action_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.asset_versions": { + "name": "asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "negative_prompt": { + "name": "negative_prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "seed": { + "name": "seed", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "asset_versions_asset_version_unique": { + "name": "asset_versions_asset_version_unique", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "asset_versions_current_idx": { + "name": "asset_versions_current_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "asset_versions_asset_id_assets_id_fk": { + "name": "asset_versions_asset_id_assets_id_fk", + "tableFrom": "asset_versions", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "asset_versions_created_by_id_users_id_fk": { + "name": "asset_versions_created_by_id_users_id_fk", + "tableFrom": "asset_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.assets": { + "name": "assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "asset_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "assets_project_type_idx": { + "name": "assets_project_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "assets_project_status_idx": { + "name": "assets_project_status_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "assets_project_id_projects_id_fk": { + "name": "assets_project_id_projects_id_fk", + "tableFrom": "assets", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_created_by_id_users_id_fk": { + "name": "assets_created_by_id_users_id_fk", + "tableFrom": "assets", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_workspace_time_idx": { + "name": "audit_logs_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_workspace_id_workspaces_id_fk": { + "name": "audit_logs_workspace_id_workspaces_id_fk", + "tableFrom": "audit_logs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "customer_id": { + "name": "customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "price_id": { + "name": "price_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true, + "default": "'no_subscription'" + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_event_created_at": { + "name": "last_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_workspace_unique": { + "name": "billing_subscriptions_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_customer_unique": { + "name": "billing_subscriptions_customer_unique", + "columns": [ + { + "expression": "customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_subscription_unique": { + "name": "billing_subscriptions_subscription_unique", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_status_idx": { + "name": "billing_subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_workspace_id_workspaces_id_fk": { + "name": "billing_subscriptions_workspace_id_workspaces_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_type": { + "name": "event_type", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "livemode": { + "name": "livemode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_webhook_events_workspace_idx": { + "name": "billing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_type_idx": { + "name": "billing_webhook_events_type_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "billing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "billing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deliveries": { + "name": "deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "published_url": { + "name": "published_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provider_event_at": { + "name": "last_provider_event_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "deliveries_render_idx": { + "name": "deliveries_render_idx", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_job_idx": { + "name": "deliveries_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_channel_account_idx": { + "name": "deliveries_channel_account_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deliveries_status_idx": { + "name": "deliveries_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deliveries_render_id_renders_id_fk": { + "name": "deliveries_render_id_renders_id_fk", + "tableFrom": "deliveries", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_job_id_generation_jobs_id_fk": { + "name": "deliveries_job_id_generation_jobs_id_fk", + "tableFrom": "deliveries", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deliveries_channel_account_id_publishing_channel_accounts_id_fk": { + "name": "deliveries_channel_account_id_publishing_channel_accounts_id_fk", + "tableFrom": "deliveries", + "tableTo": "publishing_channel_accounts", + "columnsFrom": [ + "channel_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deliveries_created_by_id_users_id_fk": { + "name": "deliveries_created_by_id_users_id_fk", + "tableFrom": "deliveries", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.episodes": { + "name": "episodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "episode_number": { + "name": "episode_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "synopsis": { + "name": "synopsis", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "target_duration_seconds": { + "name": "target_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 180 + }, + "status": { + "name": "status", + "type": "episode_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OUTLINE'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "episodes_project_number_unique": { + "name": "episodes_project_number_unique", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "episode_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "episodes_project_idx": { + "name": "episodes_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "episodes_project_id_projects_id_fk": { + "name": "episodes_project_id_projects_id_fk", + "tableFrom": "episodes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "episodes_created_by_id_users_id_fk": { + "name": "episodes_created_by_id_users_id_fk", + "tableFrom": "episodes", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generation_jobs": { + "name": "generation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "production_run_id": { + "name": "production_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "provider_model": { + "name": "provider_model", + "type": "varchar(160)", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "estimated_credits": { + "name": "estimated_credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "generation_jobs_idempotency_unique": { + "name": "generation_jobs_idempotency_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_workspace_status_idx": { + "name": "generation_jobs_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_project_idx": { + "name": "generation_jobs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "generation_jobs_production_run_idx": { + "name": "generation_jobs_production_run_idx", + "columns": [ + { + "expression": "production_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "generation_jobs_workspace_id_workspaces_id_fk": { + "name": "generation_jobs_workspace_id_workspaces_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_project_id_projects_id_fk": { + "name": "generation_jobs_project_id_projects_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_episode_id_episodes_id_fk": { + "name": "generation_jobs_episode_id_episodes_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "generation_jobs_production_run_id_production_runs_id_fk": { + "name": "generation_jobs_production_run_id_production_runs_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "production_runs", + "columnsFrom": [ + "production_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "generation_jobs_created_by_id_users_id_fk": { + "name": "generation_jobs_created_by_id_users_id_fk", + "tableFrom": "generation_jobs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_user_idx": { + "name": "memberships_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_workspace_id_workspaces_id_fk": { + "name": "memberships_workspace_id_workspaces_id_fk", + "tableFrom": "memberships", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_user_id_users_id_fk": { + "name": "memberships_user_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_invited_by_id_users_id_fk": { + "name": "memberships_invited_by_id_users_id_fk", + "tableFrom": "memberships", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_workspace_id_user_id_pk": { + "name": "memberships_workspace_id_user_id_pk", + "columns": [ + "workspace_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "notification_tone", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'INFO'" + }, + "title": { + "name": "title", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "page": { + "name": "page", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_dedupe_unique": { + "name": "notifications_user_dedupe_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_workspace_created_idx": { + "name": "notifications_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_workspace_id_workspaces_id_fk": { + "name": "notifications_workspace_id_workspaces_id_fk", + "tableFrom": "notifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.production_runs": { + "name": "production_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "render_id": { + "name": "render_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "production_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "total_jobs": { + "name": "total_jobs", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "production_runs_render_unique": { + "name": "production_runs_render_unique", + "columns": [ + { + "expression": "render_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_status_idx": { + "name": "production_runs_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "production_runs_episode_created_idx": { + "name": "production_runs_episode_created_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "production_runs_episode_id_episodes_id_fk": { + "name": "production_runs_episode_id_episodes_id_fk", + "tableFrom": "production_runs", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_render_id_renders_id_fk": { + "name": "production_runs_render_id_renders_id_fk", + "tableFrom": "production_runs", + "tableTo": "renders", + "columnsFrom": [ + "render_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "production_runs_created_by_id_users_id_fk": { + "name": "production_runs_created_by_id_users_id_fk", + "tableFrom": "production_runs", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "logline": { + "name": "logline", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "genre": { + "name": "genre", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true, + "default": "'都市悬疑'" + }, + "visual_bible": { + "name": "visual_bible", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_workspace_slug_unique": { + "name": "projects_workspace_slug_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "projects_workspace_idx": { + "name": "projects_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_workspace_id_workspaces_id_fk": { + "name": "projects_workspace_id_workspaces_id_fk", + "tableFrom": "projects", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "projects_created_by_id_users_id_fk": { + "name": "projects_created_by_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_verifications": { + "name": "provider_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "configuration_fingerprint": { + "name": "configuration_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "checked_by_id": { + "name": "checked_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_verifications_workspace_provider_unique": { + "name": "provider_verifications_workspace_provider_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "provider_verifications_workspace_status_idx": { + "name": "provider_verifications_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_verifications_workspace_id_workspaces_id_fk": { + "name": "provider_verifications_workspace_id_workspaces_id_fk", + "tableFrom": "provider_verifications", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "provider_verifications_checked_by_id_users_id_fk": { + "name": "provider_verifications_checked_by_id_users_id_fk", + "tableFrom": "provider_verifications", + "tableTo": "users", + "columnsFrom": [ + "checked_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_channel_accounts": { + "name": "publishing_channel_accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "external_account_id": { + "name": "external_account_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "publishing_channel_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CONNECTED'" + }, + "access_token_ciphertext": { + "name": "access_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ciphertext": { + "name": "refresh_token_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "connected_by_id": { + "name": "connected_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error_code": { + "name": "error_code", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_channel_accounts_external_unique": { + "name": "publishing_channel_accounts_external_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_workspace_idx": { + "name": "publishing_channel_accounts_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_channel_accounts_status_idx": { + "name": "publishing_channel_accounts_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_channel_accounts_workspace_id_workspaces_id_fk": { + "name": "publishing_channel_accounts_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_channel_accounts_connected_by_id_users_id_fk": { + "name": "publishing_channel_accounts_connected_by_id_users_id_fk", + "tableFrom": "publishing_channel_accounts", + "tableTo": "users", + "columnsFrom": [ + "connected_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_oauth_states": { + "name": "publishing_oauth_states", + "schema": "", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "code_verifier_ciphertext": { + "name": "code_verifier_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "return_path": { + "name": "return_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'/?publishing=connected'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_oauth_states_expiry_idx": { + "name": "publishing_oauth_states_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_oauth_states_workspace_idx": { + "name": "publishing_oauth_states_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_oauth_states_workspace_id_workspaces_id_fk": { + "name": "publishing_oauth_states_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_oauth_states_created_by_id_users_id_fk": { + "name": "publishing_oauth_states_created_by_id_users_id_fk", + "tableFrom": "publishing_oauth_states", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.publishing_webhook_events": { + "name": "publishing_webhook_events", + "schema": "", + "columns": { + "event_key": { + "name": "event_key", + "type": "varchar(320)", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "publishing_webhook_events_delivery_idx": { + "name": "publishing_webhook_events_delivery_idx", + "columns": [ + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_workspace_idx": { + "name": "publishing_webhook_events_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "publishing_webhook_events_platform_idx": { + "name": "publishing_webhook_events_platform_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "publishing_webhook_events_workspace_id_workspaces_id_fk": { + "name": "publishing_webhook_events_workspace_id_workspaces_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "publishing_webhook_events_delivery_id_deliveries_id_fk": { + "name": "publishing_webhook_events_delivery_id_deliveries_id_fk", + "tableFrom": "publishing_webhook_events", + "tableTo": "deliveries", + "columnsFrom": [ + "delivery_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_id": { + "name": "replaced_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.renders": { + "name": "renders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "render_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'QUEUED'" + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumbnail_key": { + "name": "thumbnail_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_srt_key": { + "name": "subtitle_srt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_vtt_key": { + "name": "subtitle_vtt_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true, + "default": "'video/mp4'" + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "source_manifest": { + "name": "source_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "quality_report": { + "name": "quality_report", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "renders_episode_version_unique": { + "name": "renders_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "renders_episode_status_idx": { + "name": "renders_episode_status_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "renders_episode_id_episodes_id_fk": { + "name": "renders_episode_id_episodes_id_fk", + "tableFrom": "renders", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "renders_created_by_id_users_id_fk": { + "name": "renders_created_by_id_users_id_fk", + "tableFrom": "renders", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_comments": { + "name": "review_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_request_id": { + "name": "review_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timecode_ms": { + "name": "timecode_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_comments_request_idx": { + "name": "review_comments_request_idx", + "columns": [ + { + "expression": "review_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_comments_review_request_id_review_requests_id_fk": { + "name": "review_comments_review_request_id_review_requests_id_fk", + "tableFrom": "review_comments", + "tableTo": "review_requests", + "columnsFrom": [ + "review_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_comments_author_id_users_id_fk": { + "name": "review_comments_author_id_users_id_fk", + "tableFrom": "review_comments", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_requests": { + "name": "review_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "review_target_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "requested_by_id": { + "name": "requested_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "assigned_to_id": { + "name": "assigned_to_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_by_id": { + "name": "resolved_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_requests_workspace_status_idx": { + "name": "review_requests_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_requests_target_idx": { + "name": "review_requests_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_requests_workspace_id_workspaces_id_fk": { + "name": "review_requests_workspace_id_workspaces_id_fk", + "tableFrom": "review_requests", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_requests_requested_by_id_users_id_fk": { + "name": "review_requests_requested_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "requested_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "review_requests_assigned_to_id_users_id_fk": { + "name": "review_requests_assigned_to_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "assigned_to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "review_requests_resolved_by_id_users_id_fk": { + "name": "review_requests_resolved_by_id_users_id_fk", + "tableFrom": "review_requests", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.script_versions": { + "name": "script_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plain_text": { + "name": "plain_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "script_versions_episode_version_unique": { + "name": "script_versions_episode_version_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "script_versions_episode_current_idx": { + "name": "script_versions_episode_current_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "script_versions_episode_id_episodes_id_fk": { + "name": "script_versions_episode_id_episodes_id_fk", + "tableFrom": "script_versions", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "script_versions_created_by_id_users_id_fk": { + "name": "script_versions_created_by_id_users_id_fk", + "tableFrom": "script_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_versions": { + "name": "shot_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_asset_version_id": { + "name": "image_asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "video_storage_key": { + "name": "video_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_storage_key": { + "name": "audio_storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtitle_text": { + "name": "subtitle_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "generation_params": { + "name": "generation_params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_current": { + "name": "is_current", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shot_versions_shot_version_unique": { + "name": "shot_versions_shot_version_unique", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shot_versions_current_idx": { + "name": "shot_versions_current_idx", + "columns": [ + { + "expression": "shot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_current", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shot_versions_shot_id_shots_id_fk": { + "name": "shot_versions_shot_id_shots_id_fk", + "tableFrom": "shot_versions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shot_versions_image_asset_version_id_asset_versions_id_fk": { + "name": "shot_versions_image_asset_version_id_asset_versions_id_fk", + "tableFrom": "shot_versions", + "tableTo": "asset_versions", + "columnsFrom": [ + "image_asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shot_versions_created_by_id_users_id_fk": { + "name": "shot_versions_created_by_id_users_id_fk", + "tableFrom": "shot_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "episode_id": { + "name": "episode_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shot_number": { + "name": "shot_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "shot_type": { + "name": "shot_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'中景'" + }, + "camera_motion": { + "name": "camera_motion", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'静止'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3000 + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "character_asset_ids": { + "name": "character_asset_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_asset_id": { + "name": "scene_asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_episode_number_unique": { + "name": "shots_episode_number_unique", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_episode_sort_idx": { + "name": "shots_episode_sort_idx", + "columns": [ + { + "expression": "episode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_episode_id_episodes_id_fk": { + "name": "shots_episode_id_episodes_id_fk", + "tableFrom": "shots", + "tableTo": "episodes", + "columnsFrom": [ + "episode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shots_scene_asset_id_assets_id_fk": { + "name": "shots_scene_asset_id_assets_id_fk", + "tableFrom": "shots", + "tableTo": "assets", + "columnsFrom": [ + "scene_asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_created_by_id_users_id_fk": { + "name": "shots_created_by_id_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "usage_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "credits": { + "name": "credits", + "type": "numeric(20, 4)", + "primaryKey": false, + "notNull": true + }, + "provider_cost_usd": { + "name": "provider_cost_usd", + "type": "numeric(20, 6)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_ledger_workspace_time_idx": { + "name": "usage_ledger_workspace_time_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_ledger_job_idx": { + "name": "usage_ledger_job_idx", + "columns": [ + { + "expression": "job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_ledger_workspace_id_workspaces_id_fk": { + "name": "usage_ledger_workspace_id_workspaces_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_ledger_user_id_users_id_fk": { + "name": "usage_ledger_user_id_users_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_ledger_job_id_generation_jobs_id_fk": { + "name": "usage_ledger_job_id_generation_jobs_id_fk", + "tableFrom": "usage_ledger", + "tableTo": "generation_jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_key": { + "name": "avatar_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "membership_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'VIEWER'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_id": { + "name": "accepted_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "invited_by_id": { + "name": "invited_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_invitations_token_hash_unique": { + "name": "workspace_invitations_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_email_idx": { + "name": "workspace_invitations_workspace_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_invitations_workspace_created_idx": { + "name": "workspace_invitations_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_workspace_id_workspaces_id_fk": { + "name": "workspace_invitations_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_accepted_by_id_users_id_fk": { + "name": "workspace_invitations_accepted_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_invitations_invited_by_id_users_id_fk": { + "name": "workspace_invitations_invited_by_id_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspaces": { + "name": "workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(80)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true, + "default": "'PRO'" + }, + "monthly_credit_limit": { + "name": "monthly_credit_limit", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspaces_owner_idx": { + "name": "workspaces_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspaces_owner_id_users_id_fk": { + "name": "workspaces_owner_id_users_id_fk", + "tableFrom": "workspaces", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.account_token_type": { + "name": "account_token_type", + "schema": "public", + "values": [ + "EMAIL_VERIFICATION", + "PASSWORD_RESET" + ] + }, + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "ARCHIVED" + ] + }, + "public.asset_type": { + "name": "asset_type", + "schema": "public", + "values": [ + "CHARACTER", + "SCENE", + "PROP", + "VOICE", + "MUSIC", + "SHOT" + ] + }, + "public.delivery_status": { + "name": "delivery_status", + "schema": "public", + "values": [ + "DRAFT", + "QUEUED", + "PUBLISHING", + "PUBLISHED", + "FAILED", + "CANCELLED" + ] + }, + "public.episode_status": { + "name": "episode_status", + "schema": "public", + "values": [ + "OUTLINE", + "SCRIPTING", + "STORYBOARDING", + "PRODUCTION", + "REVIEW", + "PUBLISHED" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "PIPELINE_HEALTHCHECK", + "SCRIPT_GENERATE", + "STORYBOARD_GENERATE", + "ASSET_GENERATE", + "TTS_GENERATE", + "VIDEO_GENERATE", + "LIPSYNC_GENERATE", + "EPISODE_RENDER", + "DELIVERY_PUBLISH" + ] + }, + "public.membership_role": { + "name": "membership_role", + "schema": "public", + "values": [ + "OWNER", + "ADMIN", + "EDITOR", + "REVIEWER", + "VIEWER" + ] + }, + "public.notification_tone": { + "name": "notification_tone", + "schema": "public", + "values": [ + "INFO", + "SUCCESS", + "WARNING", + "ERROR" + ] + }, + "public.production_run_status": { + "name": "production_run_status", + "schema": "public", + "values": [ + "QUEUED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "DRAFT", + "ACTIVE", + "ARCHIVED" + ] + }, + "public.publishing_channel_status": { + "name": "publishing_channel_status", + "schema": "public", + "values": [ + "CONNECTED", + "EXPIRED", + "REVOKED", + "ERROR" + ] + }, + "public.render_status": { + "name": "render_status", + "schema": "public", + "values": [ + "QUEUED", + "RENDERING", + "REVIEW", + "APPROVED", + "FAILED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "OPEN", + "APPROVED", + "CHANGES_REQUESTED", + "CANCELLED" + ] + }, + "public.review_target_type": { + "name": "review_target_type", + "schema": "public", + "values": [ + "SCRIPT_VERSION", + "ASSET_VERSION", + "SHOT_VERSION", + "RENDER" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "DRAFT", + "GENERATING", + "REVIEW", + "APPROVED", + "RENDERED" + ] + }, + "public.usage_kind": { + "name": "usage_kind", + "schema": "public", + "values": [ + "TEXT_INPUT_TOKEN", + "TEXT_OUTPUT_TOKEN", + "IMAGE", + "AUDIO_SECOND", + "VIDEO_SECOND", + "RENDER_SECOND", + "STORAGE_BYTE_MONTH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..762fd09 --- /dev/null +++ b/server/drizzle/meta/_journal.json @@ -0,0 +1,125 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1785398872080, + "tag": "0000_last_spectrum", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785400342760, + "tag": "0001_perfect_gravity", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785401841259, + "tag": "0002_yielding_spencer_smythe", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785404103710, + "tag": "0003_hard_norrin_radd", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785405889215, + "tag": "0004_goofy_karnak", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1785431751467, + "tag": "0005_brave_betty_ross", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1785432812502, + "tag": "0006_wooden_texas_twister", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1785434191972, + "tag": "0007_sudden_roland_deschain", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1785435268450, + "tag": "0008_red_trish_tilby", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1785437272781, + "tag": "0009_chilly_mockingbird", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1785439338694, + "tag": "0010_sharp_odin", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1785446641562, + "tag": "0011_futuristic_stature", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1785450378611, + "tag": "0012_warm_old_lace", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1785450438724, + "tag": "0013_lethal_giant_man", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1785450621103, + "tag": "0014_heavy_retro_girl", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1785450684178, + "tag": "0015_reflective_true_believers", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1785456376158, + "tag": "0016_amused_longshot", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..7873659 --- /dev/null +++ b/server/package.json @@ -0,0 +1,52 @@ +{ + "name": "@frameflow/server", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "worker": "tsx watch src/worker.ts", + "start": "node dist/index.js", + "start:worker": "node dist/worker.js", + "config:check": "tsx src/config-check.ts", + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:seed": "tsx src/db/seed.ts", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.850.0", + "@aws-sdk/s3-request-presigner": "^3.850.0", + "@fastify/cookie": "^11.0.2", + "@fastify/cors": "^11.1.0", + "@fastify/helmet": "^13.0.1", + "@fastify/jwt": "^10.0.0", + "@fastify/multipart": "^9.2.1", + "@fastify/rate-limit": "^10.3.0", + "@fastify/sensible": "^6.0.3", + "argon2": "^0.44.0", + "bullmq": "^5.58.0", + "dotenv": "^17.2.1", + "drizzle-orm": "^0.45.2", + "fastify": "^5.5.0", + "fastify-raw-body": "^5.0.0", + "ioredis": "^5.7.0", + "ipaddr.js": "^2.4.0", + "openai": "^7.2.0", + "pino": "^10.3.1", + "postgres": "^3.4.7", + "prom-client": "^15.1.3", + "stripe": "^22.4.0", + "zod": "^4.0.17" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "drizzle-kit": "^0.31.5", + "tsx": "^4.20.4", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..1d9e796 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,168 @@ +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import helmet from '@fastify/helmet' +import jwt from '@fastify/jwt' +import multipart from '@fastify/multipart' +import rateLimit from '@fastify/rate-limit' +import sensible from '@fastify/sensible' +import rawBody from 'fastify-raw-body' +import Fastify from 'fastify' +import { sql } from 'drizzle-orm' +import { ZodError } from 'zod' +import { config } from './config.js' +import { db } from './db/client.js' +import { AppError } from './lib/errors.js' +import { apiRequestDurationSeconds, apiRequestsTotal, applicationDependencyAvailable, metricsRegistry } from './lib/metrics.js' +import { authRoutes } from './routes/auth.js' +import { assetRoutes } from './routes/assets.js' +import { episodeRoutes } from './routes/episodes.js' +import { projectRoutes } from './routes/projects.js' +import { shotRoutes } from './routes/shots.js' +import { workspaceRoutes } from './routes/workspaces.js' +import { jobRoutes } from './routes/jobs.js' +import { uploadRoutes } from './routes/uploads.js' +import { productionRoutes } from './routes/production.js' +import { reviewRoutes } from './routes/reviews.js' +import { billingRoutes } from './routes/billing.js' +import { readinessRoutes } from './routes/readiness.js' +import { auditRoutes } from './routes/audit.js' +import { notificationRoutes } from './routes/notifications.js' +import { publishingRoutes } from './routes/publishing.js' +import type { StripeBillingProvider } from './lib/stripe-billing.js' +import type { PublishingAdapterRegistry } from './publishing/types.js' +import type { InvitationMailer } from './lib/mailer.js' +import type { ProviderConfiguration } from './providers/readiness.js' +import { checkRedis, redis } from './queue/connection.js' +import { closeQueue } from './queue/client.js' +import { checkWorkerHeartbeat } from './queue/heartbeat.js' +import { checkStorage, closeStorage } from './storage/client.js' + +export interface BuildAppOptions { + billingProvider?: StripeBillingProvider + invitationMailer?: InvitationMailer + publishingAdapters?: PublishingAdapterRegistry + publishingWebhookSecret?: string + providerVerificationFetch?: typeof fetch + providerVerificationConfiguration?: ProviderConfiguration +} + +export async function buildApp(options: BuildAppOptions = {}) { + const app = Fastify({ + logger: { level: config.LOG_LEVEL }, + requestIdHeader: 'x-request-id', + trustProxy: config.NODE_ENV === 'production', + bodyLimit: config.MAX_UPLOAD_BYTES, + }) + + await app.register(helmet, { contentSecurityPolicy: config.NODE_ENV === 'production' }) + await app.register(cors, { + origin: config.WEB_ORIGIN.split(',').map((origin) => origin.trim()), + credentials: true, + methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }) + await app.register(cookie) + await app.register(sensible) + await app.register(rawBody, { global: false, encoding: false, runFirst: true }) + await app.register(rateLimit, { max: 120, timeWindow: '1 minute' }) + await app.register(multipart, { + limits: { fileSize: config.MAX_UPLOAD_BYTES, files: 8 }, + }) + await app.register(jwt, { secret: config.JWT_ACCESS_SECRET }) + + app.get('/health/live', async () => ({ status: 'ok', service: 'frameflow-api' })) + app.get('/health/ready', async (_request, reply) => { + const checks = await Promise.allSettled([ + db.execute(sql`select 1`), + checkRedis(), + checkStorage(), + checkWorkerHeartbeat(), + ]) + const names = ['postgres', 'redis', 'objectStorage', 'worker'] as const + const dependencies = Object.fromEntries(names.map((name, index) => [name, checks[index]?.status === 'fulfilled' ? 'ok' : 'unavailable'])) + names.forEach((name, index) => applicationDependencyAvailable.set({ dependency: name }, checks[index]?.status === 'fulfilled' ? 1 : 0)) + const ready = checks.every((check) => check.status === 'fulfilled') + return reply.code(ready ? 200 : 503).send({ status: ready ? 'ready' : 'not_ready', dependencies }) + }) + app.get('/metrics', async (request, reply) => { + if (config.METRICS_TOKEN && request.headers.authorization !== `Bearer ${config.METRICS_TOKEN}`) { + return reply.code(401).send({ error: { code: 'UNAUTHORIZED', message: 'Metrics token is missing or invalid' } }) + } + return reply.header('content-type', metricsRegistry.contentType).send(await metricsRegistry.metrics()) + }) + + app.addHook('onResponse', async (request, reply) => { + const labels = { + method: request.method, + route: request.routeOptions.url ?? 'unmatched', + status: String(reply.statusCode), + } + apiRequestsTotal.inc(labels) + apiRequestDurationSeconds.observe(labels, reply.elapsedTime / 1000) + }) + + app.addHook('onClose', async () => { + await Promise.allSettled([closeQueue(), redis.quit()]) + closeStorage() + }) + + app.setNotFoundHandler((request, reply) => { + reply.code(404).send({ error: { code: 'NOT_FOUND', message: `Route ${request.method} ${request.url} not found` } }) + }) + + app.setErrorHandler((error, request, reply) => { + const appError = error as AppError + const businessCodes = new Set(['BAD_REQUEST', 'UNAUTHORIZED', 'EMAIL_VERIFICATION_REQUIRED', 'FORBIDDEN', 'NOT_FOUND', 'CONFLICT', 'CREDIT_LIMIT_EXCEEDED', 'SERVICE_UNAVAILABLE']) + if (error instanceof AppError || appError.name === 'AppError' || businessCodes.has(appError.code)) { + return reply.code(appError.statusCode).send({ + error: { code: appError.code, message: appError.message, details: appError.details }, + requestId: request.id, + }) + } + if (error instanceof ZodError) { + return reply.code(400).send({ + error: { code: 'BAD_REQUEST', message: 'Request validation failed', details: error.issues }, + requestId: request.id, + }) + } + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') { + return reply.code(409).send({ error: { code: 'CONFLICT', message: 'A unique value already exists' }, requestId: request.id }) + } + request.log.error({ err: error }, 'Unhandled request error') + return reply.code(500).send({ error: { code: 'INTERNAL_ERROR', message: 'Unexpected server error' }, requestId: request.id }) + }) + + await app.register(authRoutes, { prefix: '/api/v1/auth' }) + await app.register(workspaceRoutes, { + prefix: '/api/v1/workspaces', + ...(options.invitationMailer ? { invitationMailer: options.invitationMailer } : {}), + }) + await app.register(projectRoutes, { prefix: '/api/v1' }) + await app.register(episodeRoutes, { prefix: '/api/v1' }) + await app.register(assetRoutes, { prefix: '/api/v1' }) + await app.register(shotRoutes, { prefix: '/api/v1' }) + await app.register(uploadRoutes, { prefix: '/api/v1' }) + await app.register(jobRoutes, { prefix: '/api/v1' }) + await app.register(productionRoutes, { + prefix: '/api/v1', + ...(options.publishingAdapters ? { publishingAdapters: options.publishingAdapters } : {}), + }) + await app.register(reviewRoutes, { prefix: '/api/v1' }) + await app.register(billingRoutes, { + prefix: '/api/v1', + ...(options.billingProvider ? { billingProvider: options.billingProvider } : {}), + }) + await app.register(readinessRoutes, { + prefix: '/api/v1', + ...(options.providerVerificationFetch ? { providerVerificationFetch: options.providerVerificationFetch } : {}), + ...(options.providerVerificationConfiguration ? { providerVerificationConfiguration: options.providerVerificationConfiguration } : {}), + }) + await app.register(auditRoutes, { prefix: '/api/v1' }) + await app.register(notificationRoutes, { prefix: '/api/v1' }) + await app.register(publishingRoutes, { + prefix: '/api/v1', + ...(options.publishingAdapters ? { publishingAdapters: options.publishingAdapters } : {}), + ...(options.publishingWebhookSecret ? { publishingWebhookSecret: options.publishingWebhookSecret } : {}), + }) + + return app +} diff --git a/server/src/config-check.ts b/server/src/config-check.ts new file mode 100644 index 0000000..204f75e --- /dev/null +++ b/server/src/config-check.ts @@ -0,0 +1,22 @@ +import { config } from './config.js' + +const replicateCapabilities = [ + config.REPLICATE_IMAGE_VERSION && 'image', + config.REPLICATE_VIDEO_VERSION && 'video', + config.REPLICATE_LIPSYNC_VERSION && 'lipsync', +].filter(Boolean) + +console.log(JSON.stringify({ + status: 'valid', + environment: config.NODE_ENV, + emailDelivery: config.RESEND_API_KEY ? 'configured' : 'disabled', + openai: config.OPENAI_API_KEY ? 'configured' : 'disabled', + agnes: config.AGNES_API_KEY ? ['text', 'image', 'video'] : [], + generic: [ + config.GENERIC_TEXT_API_KEY && config.GENERIC_TEXT_BASE_URL && config.GENERIC_TEXT_MODEL && 'text', + config.GENERIC_IMAGE_API_KEY && config.GENERIC_IMAGE_BASE_URL && config.GENERIC_IMAGE_MODEL && 'image', + config.GENERIC_VIDEO_API_KEY && config.GENERIC_VIDEO_BASE_URL && config.GENERIC_VIDEO_MODEL && 'video', + ].filter(Boolean), + replicate: config.REPLICATE_API_TOKEN ? replicateCapabilities : [], + stripe: config.STRIPE_SECRET_KEY ? 'configured' : 'disabled', +})) diff --git a/server/src/config.test.ts b/server/src/config.test.ts new file mode 100644 index 0000000..a910188 --- /dev/null +++ b/server/src/config.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { parseConfig } from './config.js' + +function productionEnvironment(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + NODE_ENV: 'production', + WEB_ORIGIN: 'https://studio.frameflow.cn', + API_PUBLIC_ORIGIN: 'https://studio.frameflow.cn', + DATABASE_URL: 'postgresql://frameflow:postgres-password-32-characters@postgres:5432/frameflow', + REDIS_URL: 'redis://default:redis-password-32-characters@redis:6379', + JWT_ACCESS_SECRET: 'access-secret-with-at-least-32-characters', + JWT_REFRESH_SECRET: 'refresh-secret-with-at-least-32-characters', + CREDENTIAL_ENCRYPTION_KEY: 'credential-encryption-secret-with-32-characters', + EMAIL_VERIFICATION_REQUIRED: 'true', + RESEND_API_KEY: 're_live_frameflow_configuration', + EMAIL_FROM: 'FrameFlow ', + S3_ENDPOINT: 'http://minio:9000', + S3_PUBLIC_ENDPOINT: 'https://media.frameflow.cn', + S3_ACCESS_KEY: 'frameflow-production-access', + S3_SECRET_KEY: 'storage-secret-with-at-least-32-characters', + ...overrides, + } +} + +describe('production environment validation', () => { + it('accepts a complete production configuration with optional providers disabled', () => { + const result = parseConfig(productionEnvironment()) + expect(result.NODE_ENV).toBe('production') + expect(result.EMAIL_VERIFICATION_REQUIRED).toBe(true) + expect(result.S3_PUBLIC_ENDPOINT).toBe('https://media.frameflow.cn') + }) + + it('rejects placeholder secrets without exposing their values', () => { + expect(() => parseConfig(productionEnvironment({ + JWT_ACCESS_SECRET: 'replace_with_at_least_32_random_characters_access', + }))).toThrow('JWT_ACCESS_SECRET contains a placeholder value') + }) + + it('requires real HTTPS public origins', () => { + expect(() => parseConfig(productionEnvironment({ + WEB_ORIGIN: 'http://studio.example.com', + S3_PUBLIC_ENDPOINT: 'http://127.0.0.1:9000', + }))).toThrow(/WEB_ORIGIN\[0\] must use HTTPS in production/) + }) + + it('requires the public storage endpoint to be a bare origin', () => { + expect(() => parseConfig(productionEnvironment({ + S3_PUBLIC_ENDPOINT: 'https://media.frameflow.cn/private-path', + }))).toThrow('S3_PUBLIC_ENDPOINT must be an origin') + }) + + it('requires distinct signing secrets', () => { + const sharedSecret = 'shared-signing-secret-with-32-characters' + expect(() => parseConfig(productionEnvironment({ + JWT_ACCESS_SECRET: sharedSecret, + JWT_REFRESH_SECRET: sharedSecret, + }))).toThrow('JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different') + }) + + it('rejects Replicate versions without an API token', () => { + expect(() => parseConfig(productionEnvironment({ + REPLICATE_VIDEO_VERSION: 'video-model-version', + }))).toThrow('REPLICATE_API_TOKEN is required') + }) + + it('bounds the Replicate prediction deadline to the provider-supported range', () => { + expect(() => parseConfig(productionEnvironment({ + REPLICATE_PREDICTION_DEADLINE_SECONDS: '4', + }))).toThrow('REPLICATE_PREDICTION_DEADLINE_SECONDS') + expect(() => parseConfig(productionEnvironment({ + REPLICATE_PREDICTION_DEADLINE_SECONDS: '86401', + }))).toThrow('REPLICATE_PREDICTION_DEADLINE_SECONDS') + }) + + it('requires each generic provider capability to be configured as a complete triple', () => { + expect(() => parseConfig(productionEnvironment({ + GENERIC_IMAGE_API_KEY: 'generic-image-secret', + GENERIC_IMAGE_MODEL: 'vendor-image-model', + }))).toThrow('GENERIC_IMAGE requires API_KEY, BASE_URL, and MODEL together') + + const result = parseConfig(productionEnvironment({ + GENERIC_TEXT_API_KEY: 'generic-text-secret', + GENERIC_TEXT_BASE_URL: 'https://text.vendor.cn/v1', + GENERIC_TEXT_MODEL: 'vendor-text-model', + })) + expect(result.GENERIC_TEXT_BASE_URL).toBe('https://text.vendor.cn/v1') + + expect(() => parseConfig(productionEnvironment({ + GENERIC_VIDEO_API_KEY: 'generic-video-secret', + GENERIC_VIDEO_BASE_URL: 'https://video.vendor.cn/v1?route=private', + GENERIC_VIDEO_MODEL: 'vendor-video-model', + }))).toThrow('GENERIC_VIDEO_BASE_URL must not contain credentials, query parameters, or a fragment') + }) + + it('rejects a partially configured Stripe integration', () => { + expect(() => parseConfig(productionEnvironment({ + STRIPE_SECRET_KEY: 'sk_live_frameflow', + STRIPE_WEBHOOK_SECRET: 'whsec_frameflow', + }))).toThrow('Stripe production billing must configure') + }) + + it('requires independent credential encryption and complete publishing connector configuration', () => { + const accessSecret = 'access-secret-with-at-least-32-characters' + expect(() => parseConfig(productionEnvironment({ + JWT_ACCESS_SECRET: accessSecret, + CREDENTIAL_ENCRYPTION_KEY: accessSecret, + }))).toThrow('CREDENTIAL_ENCRYPTION_KEY must be distinct') + + expect(() => parseConfig(productionEnvironment({ + PUBLISHING_CONNECTOR_URL: 'https://publisher.frameflow.cn', + }))).toThrow('Publishing connector must configure') + }) +}) diff --git a/server/src/config.ts b/server/src/config.ts new file mode 100644 index 0000000..ba256e7 --- /dev/null +++ b/server/src/config.ts @@ -0,0 +1,279 @@ +import 'dotenv/config' +import { z } from 'zod' + +const booleanFromEnv = z.preprocess((value) => { + if (typeof value === 'string') return value.toLowerCase() === 'true' + return value +}, z.boolean()) + +const optionalSecret = (minimumLength = 1) => z.preprocess( + (value) => typeof value === 'string' && value.trim() === '' ? undefined : value, + z.string().min(minimumLength).optional(), +) + +const optionalUrl = z.preprocess( + (value) => typeof value === 'string' && value.trim() === '' ? undefined : value, + z.string().url().optional(), +) + +const schema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + API_HOST: z.string().default('127.0.0.1'), + API_PORT: z.coerce.number().int().positive().default(8787), + API_PUBLIC_ORIGIN: z.string().url().default('http://127.0.0.1:8787'), + WEB_ORIGIN: z.string().default('http://127.0.0.1:4173'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), + METRICS_HOST: z.string().default('127.0.0.1'), + WORKER_METRICS_PORT: z.coerce.number().int().positive().default(9092), + WORKER_HEARTBEAT_KEY: z.string().min(1).default('frameflow:worker:heartbeat'), + WORKER_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000), + WORKER_HEARTBEAT_TTL_SECONDS: z.coerce.number().int().min(5).default(20), + METRICS_TOKEN: optionalSecret(20), + DATABASE_URL: z.string().url().default('postgresql://frameflow:frameflow@127.0.0.1:54329/frameflow'), + REDIS_URL: z.string().url().default('redis://127.0.0.1:63799'), + JWT_ACCESS_SECRET: z.string().min(32).default('local-access-secret-replace-before-prod'), + JWT_REFRESH_SECRET: z.string().min(32).default('local-refresh-secret-replace-before-prod'), + CREDENTIAL_ENCRYPTION_KEY: z.string().min(32).default('local-credential-encryption-key-replace-before-prod'), + ACCESS_TOKEN_TTL: z.string().default('15m'), + REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30), + EMAIL_VERIFICATION_REQUIRED: booleanFromEnv.optional(), + EMAIL_VERIFICATION_TTL_HOURS: z.coerce.number().int().min(1).max(168).default(24), + PASSWORD_RESET_TTL_MINUTES: z.coerce.number().int().min(5).max(1440).default(30), + RESEND_API_KEY: optionalSecret(), + EMAIL_FROM: z.string().trim().min(3).default('FrameFlow '), + S3_ENDPOINT: z.string().url().default('http://127.0.0.1:9000'), + S3_PUBLIC_ENDPOINT: z.string().url().optional(), + S3_REGION: z.string().default('us-east-1'), + S3_BUCKET: z.string().default('frameflow'), + S3_ACCESS_KEY: z.string().default('frameflow'), + S3_SECRET_KEY: z.string().default('frameflow-local-secret'), + S3_FORCE_PATH_STYLE: booleanFromEnv.default(true), + OPENAI_API_KEY: optionalSecret(), + OPENAI_TEXT_MODEL: z.string().default('gpt-5.6-sol'), + OPENAI_IMAGE_MODEL: z.string().default('gpt-image-2'), + OPENAI_TTS_MODEL: z.string().default('gpt-4o-mini-tts'), + OPENAI_TTS_VOICE: z.string().default('alloy'), + AGNES_API_KEY: optionalSecret(), + AGNES_TEXT_MODEL: z.string().default('agnes-2.5-flash'), + AGNES_IMAGE_MODEL: z.string().default('agnes-image-2.1-flash'), + AGNES_VIDEO_MODEL: z.string().default('agnes-video-v2.0'), + AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(30).max(86_400).default(900), + AGNES_VIDEO_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).max(60_000).default(4000), + GENERIC_TEXT_API_KEY: optionalSecret(), + GENERIC_TEXT_BASE_URL: optionalUrl, + GENERIC_TEXT_MODEL: optionalSecret(), + GENERIC_IMAGE_API_KEY: optionalSecret(), + GENERIC_IMAGE_BASE_URL: optionalUrl, + GENERIC_IMAGE_MODEL: optionalSecret(), + GENERIC_VIDEO_API_KEY: optionalSecret(), + GENERIC_VIDEO_BASE_URL: optionalUrl, + GENERIC_VIDEO_MODEL: optionalSecret(), + GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(5).max(86_400).default(900), + GENERIC_VIDEO_POLL_INTERVAL_MS: z.coerce.number().int().min(250).max(60_000).default(4000), + REPLICATE_API_TOKEN: optionalSecret(), + REPLICATE_IMAGE_VERSION: optionalSecret(), + REPLICATE_VIDEO_VERSION: optionalSecret(), + REPLICATE_LIPSYNC_VERSION: optionalSecret(), + REPLICATE_PREDICTION_DEADLINE_SECONDS: z.coerce.number().int().min(5).max(86_400).default(900), + STRIPE_SECRET_KEY: optionalSecret(), + STRIPE_WEBHOOK_SECRET: optionalSecret(), + STRIPE_PRO_PRICE_ID: optionalSecret(), + STRIPE_STUDIO_PRICE_ID: optionalSecret(), + STRIPE_PORTAL_CONFIGURATION_ID: optionalSecret(), + BILLING_SUCCESS_URL: optionalUrl, + BILLING_CANCEL_URL: optionalUrl, + PUBLISHING_CONNECTOR_URL: optionalUrl, + PUBLISHING_CONNECTOR_SECRET: optionalSecret(24), + PUBLISHING_CONNECTOR_TIMEOUT_MS: z.coerce.number().int().min(1000).max(120_000).default(30_000), + FFMPEG_PATH: z.string().default('ffmpeg'), + FFPROBE_PATH: z.string().default('ffprobe'), + WORKER_CONCURRENCY: z.coerce.number().int().positive().max(20).default(2), + MAX_UPLOAD_BYTES: z.coerce.number().int().positive().default(100 * 1024 * 1024), + PROVIDER_DOWNLOAD_TIMEOUT_MS: z.coerce.number().int().min(1000).max(600_000).default(180_000), + PROVIDER_DOWNLOAD_MAX_REDIRECTS: z.coerce.number().int().min(0).max(10).default(3), +}) + +type ParsedConfig = z.infer + +function parseUrl(name: string, value: string, issues: string[]) { + try { + return new URL(value) + } catch { + issues.push(`${name} must be a valid URL`) + return null + } +} + +function isLocalHostname(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '0.0.0.0' + || normalized === '::1' +} + +function isDocumentationHostname(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized === 'example.com' || normalized.endsWith('.example.com') +} + +function looksLikePlaceholder(value: string) { + const normalized = value.trim().toLowerCase() + return normalized.startsWith('replace_') + || normalized.startsWith('replace-') + || normalized.includes('replace-before-prod') + || normalized.includes('change-me') + || normalized.includes('changeme') + || normalized === 'frameflow-local-secret' +} + +function validateOrigin(name: string, value: string, issues: string[]) { + const url = parseUrl(name, value, issues) + if (!url) return + if (url.protocol !== 'https:') issues.push(`${name} must use HTTPS in production`) + if (isLocalHostname(url.hostname) || isDocumentationHostname(url.hostname)) { + issues.push(`${name} must use a real public hostname in production`) + } + if (url.username || url.password || url.pathname !== '/' || url.search || url.hash) { + issues.push(`${name} must be an origin without credentials, path, query, or fragment`) + } +} + +function validatePublicUrl(name: string, value: string | undefined, issues: string[], required = false) { + if (!value) { + if (required) issues.push(`${name} is required in production`) + return + } + const url = parseUrl(name, value, issues) + if (!url) return + if (url.protocol !== 'https:') issues.push(`${name} must use HTTPS in production`) + if (isLocalHostname(url.hostname) || isDocumentationHostname(url.hostname)) { + issues.push(`${name} must use a real public hostname in production`) + } +} + +function validateProviderBaseUrl(name: string, value: string, issues: string[]) { + validatePublicUrl(name, value, issues, true) + const url = parseUrl(name, value, issues) + if (!url) return + if (url.username || url.password || url.search || url.hash) { + issues.push(`${name} must not contain credentials, query parameters, or a fragment`) + } +} + +function validateServiceUrl(name: string, value: string, protocols: string[], issues: string[]) { + const url = parseUrl(name, value, issues) + if (!url) return + if (!protocols.includes(url.protocol)) issues.push(`${name} must use ${protocols.join(' or ')}`) + if (isLocalHostname(url.hostname)) issues.push(`${name} must not target localhost in production`) + if (!url.username) issues.push(`${name} must include a username in production`) + if (!url.password) issues.push(`${name} must include authentication credentials in production`) + if (looksLikePlaceholder(url.password)) issues.push(`${name} contains a placeholder password`) +} + +function validateProductionConfiguration(data: ParsedConfig) { + const issues: string[] = [] + const origins = data.WEB_ORIGIN.split(',').map((origin) => origin.trim()).filter(Boolean) + if (origins.length === 0) issues.push('WEB_ORIGIN must include at least one origin') + origins.forEach((origin, index) => validateOrigin(`WEB_ORIGIN[${index}]`, origin, issues)) + validateOrigin('API_PUBLIC_ORIGIN', data.API_PUBLIC_ORIGIN, issues) + + validateServiceUrl('DATABASE_URL', data.DATABASE_URL, ['postgres:', 'postgresql:'], issues) + validateServiceUrl('REDIS_URL', data.REDIS_URL, ['redis:', 'rediss:'], issues) + if (data.S3_PUBLIC_ENDPOINT) validateOrigin('S3_PUBLIC_ENDPOINT', data.S3_PUBLIC_ENDPOINT, issues) + else issues.push('S3_PUBLIC_ENDPOINT is required in production') + validatePublicUrl('BILLING_SUCCESS_URL', data.BILLING_SUCCESS_URL, issues) + validatePublicUrl('BILLING_CANCEL_URL', data.BILLING_CANCEL_URL, issues) + + const storageEndpoint = parseUrl('S3_ENDPOINT', data.S3_ENDPOINT, issues) + if (storageEndpoint) { + if (!['http:', 'https:'].includes(storageEndpoint.protocol)) issues.push('S3_ENDPOINT must use http: or https:') + if (isLocalHostname(storageEndpoint.hostname)) issues.push('S3_ENDPOINT must not target localhost in production') + } + + const secretValues: Array<[string, string | undefined]> = [ + ['JWT_ACCESS_SECRET', data.JWT_ACCESS_SECRET], + ['JWT_REFRESH_SECRET', data.JWT_REFRESH_SECRET], + ['CREDENTIAL_ENCRYPTION_KEY', data.CREDENTIAL_ENCRYPTION_KEY], + ['S3_ACCESS_KEY', data.S3_ACCESS_KEY], + ['S3_SECRET_KEY', data.S3_SECRET_KEY], + ['RESEND_API_KEY', data.RESEND_API_KEY], + ['OPENAI_API_KEY', data.OPENAI_API_KEY], + ['AGNES_API_KEY', data.AGNES_API_KEY], + ['GENERIC_TEXT_API_KEY', data.GENERIC_TEXT_API_KEY], + ['GENERIC_IMAGE_API_KEY', data.GENERIC_IMAGE_API_KEY], + ['GENERIC_VIDEO_API_KEY', data.GENERIC_VIDEO_API_KEY], + ['REPLICATE_API_TOKEN', data.REPLICATE_API_TOKEN], + ['STRIPE_SECRET_KEY', data.STRIPE_SECRET_KEY], + ['STRIPE_WEBHOOK_SECRET', data.STRIPE_WEBHOOK_SECRET], + ['STRIPE_PRO_PRICE_ID', data.STRIPE_PRO_PRICE_ID], + ['STRIPE_STUDIO_PRICE_ID', data.STRIPE_STUDIO_PRICE_ID], + ['PUBLISHING_CONNECTOR_SECRET', data.PUBLISHING_CONNECTOR_SECRET], + ] + secretValues.forEach(([name, value]) => { + if (value && looksLikePlaceholder(value)) issues.push(`${name} contains a placeholder value`) + }) + if (data.JWT_ACCESS_SECRET === data.JWT_REFRESH_SECRET) { + issues.push('JWT_ACCESS_SECRET and JWT_REFRESH_SECRET must be different') + } + if (data.CREDENTIAL_ENCRYPTION_KEY === data.JWT_ACCESS_SECRET || data.CREDENTIAL_ENCRYPTION_KEY === data.JWT_REFRESH_SECRET) { + issues.push('CREDENTIAL_ENCRYPTION_KEY must be distinct from JWT signing secrets') + } + if (data.S3_SECRET_KEY.length < 16) issues.push('S3_SECRET_KEY must contain at least 16 characters in production') + + const emailVerificationRequired = data.EMAIL_VERIFICATION_REQUIRED ?? true + if (emailVerificationRequired && !data.RESEND_API_KEY) { + issues.push('RESEND_API_KEY is required when production email verification is enabled') + } + if (data.RESEND_API_KEY && (/frameflow\.local/i.test(data.EMAIL_FROM) || /@example\.com/i.test(data.EMAIL_FROM))) { + issues.push('EMAIL_FROM must use a verified non-placeholder sender in production') + } + + const replicateVersions = [data.REPLICATE_IMAGE_VERSION, data.REPLICATE_VIDEO_VERSION, data.REPLICATE_LIPSYNC_VERSION] + if (replicateVersions.some(Boolean) && !data.REPLICATE_API_TOKEN) { + issues.push('REPLICATE_API_TOKEN is required when a Replicate model version is configured') + } + + const genericCapabilities = [ + ['GENERIC_TEXT', data.GENERIC_TEXT_API_KEY, data.GENERIC_TEXT_BASE_URL, data.GENERIC_TEXT_MODEL], + ['GENERIC_IMAGE', data.GENERIC_IMAGE_API_KEY, data.GENERIC_IMAGE_BASE_URL, data.GENERIC_IMAGE_MODEL], + ['GENERIC_VIDEO', data.GENERIC_VIDEO_API_KEY, data.GENERIC_VIDEO_BASE_URL, data.GENERIC_VIDEO_MODEL], + ] as const + for (const [name, apiKey, baseUrl, model] of genericCapabilities) { + const values = [apiKey, baseUrl, model] + if (values.some(Boolean) && values.some((value) => !value)) { + issues.push(`${name} requires API_KEY, BASE_URL, and MODEL together`) + } + if (baseUrl) validateProviderBaseUrl(`${name}_BASE_URL`, baseUrl, issues) + } + + const stripeValues = [data.STRIPE_SECRET_KEY, data.STRIPE_WEBHOOK_SECRET, data.STRIPE_PRO_PRICE_ID, data.STRIPE_STUDIO_PRICE_ID] + if (stripeValues.some(Boolean) && stripeValues.some((value) => !value)) { + issues.push('Stripe production billing must configure STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID, and STRIPE_STUDIO_PRICE_ID together') + } + + const publishingConnectorValues = [data.PUBLISHING_CONNECTOR_URL, data.PUBLISHING_CONNECTOR_SECRET] + if (publishingConnectorValues.some(Boolean) && publishingConnectorValues.some((value) => !value)) { + issues.push('Publishing connector must configure PUBLISHING_CONNECTOR_URL and PUBLISHING_CONNECTOR_SECRET together') + } + if (data.PUBLISHING_CONNECTOR_URL) { + validatePublicUrl('PUBLISHING_CONNECTOR_URL', data.PUBLISHING_CONNECTOR_URL, issues, true) + } + + if (issues.length > 0) throw new Error(`Unsafe production environment configuration:\n${issues.join('\n')}`) +} + +export function parseConfig(environment: NodeJS.ProcessEnv) { + const parsed = schema.safeParse(environment) + if (!parsed.success) { + const details = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('\n') + throw new Error(`Invalid environment configuration:\n${details}`) + } + + if (parsed.data.NODE_ENV === 'production') validateProductionConfiguration(parsed.data) + const emailVerificationRequired = parsed.data.EMAIL_VERIFICATION_REQUIRED ?? parsed.data.NODE_ENV === 'production' + return { ...parsed.data, EMAIL_VERIFICATION_REQUIRED: emailVerificationRequired } +} + +export const config = parseConfig(process.env) +export type Config = typeof config diff --git a/server/src/db/client.ts b/server/src/db/client.ts new file mode 100644 index 0000000..57a93a4 --- /dev/null +++ b/server/src/db/client.ts @@ -0,0 +1,17 @@ +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { config } from '../config.js' +import * as schema from './schema.js' + +export const sqlClient = postgres(config.DATABASE_URL, { + max: config.NODE_ENV === 'test' ? 2 : 10, + idle_timeout: 20, + connect_timeout: 10, + prepare: false, +}) + +export const db = drizzle(sqlClient, { schema }) + +export async function closeDatabase() { + await sqlClient.end({ timeout: 5 }) +} diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..a93c3b9 --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,9 @@ +import { migrate } from 'drizzle-orm/postgres-js/migrator' +import { closeDatabase, db } from './client.js' + +try { + await migrate(db, { migrationsFolder: new URL('../../drizzle', import.meta.url).pathname }) + console.info('Database migrations applied') +} finally { + await closeDatabase() +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..2a2b5ce --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,566 @@ +import { + boolean, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core' + +const timestamps = { + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +} + +export const membershipRole = pgEnum('membership_role', [ + 'OWNER', + 'ADMIN', + 'EDITOR', + 'REVIEWER', + 'VIEWER', +]) +export const projectStatus = pgEnum('project_status', ['DRAFT', 'ACTIVE', 'ARCHIVED']) +export const episodeStatus = pgEnum('episode_status', [ + 'OUTLINE', + 'SCRIPTING', + 'STORYBOARDING', + 'PRODUCTION', + 'REVIEW', + 'PUBLISHED', +]) +export const assetType = pgEnum('asset_type', ['CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC', 'SHOT']) +export const assetStatus = pgEnum('asset_status', ['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED']) +export const shotStatus = pgEnum('shot_status', ['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'RENDERED']) +export const jobType = pgEnum('job_type', [ + 'PIPELINE_HEALTHCHECK', + 'SCRIPT_GENERATE', + 'STORYBOARD_GENERATE', + 'ASSET_GENERATE', + 'TTS_GENERATE', + 'VIDEO_GENERATE', + 'LIPSYNC_GENERATE', + 'EPISODE_RENDER', + 'DELIVERY_PUBLISH', +]) +export const jobStatus = pgEnum('job_status', ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED']) +export const productionRunStatus = pgEnum('production_run_status', ['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED']) +export const reviewStatus = pgEnum('review_status', ['OPEN', 'APPROVED', 'CHANGES_REQUESTED', 'CANCELLED']) +export const reviewTargetType = pgEnum('review_target_type', ['SCRIPT_VERSION', 'ASSET_VERSION', 'SHOT_VERSION', 'RENDER']) +export const renderStatus = pgEnum('render_status', ['QUEUED', 'RENDERING', 'REVIEW', 'APPROVED', 'FAILED']) +export const deliveryStatus = pgEnum('delivery_status', ['DRAFT', 'QUEUED', 'PUBLISHING', 'PUBLISHED', 'FAILED', 'CANCELLED']) +export const accountTokenType = pgEnum('account_token_type', ['EMAIL_VERIFICATION', 'PASSWORD_RESET']) +export const usageKind = pgEnum('usage_kind', [ + 'TEXT_INPUT_TOKEN', + 'TEXT_OUTPUT_TOKEN', + 'IMAGE', + 'AUDIO_SECOND', + 'VIDEO_SECOND', + 'RENDER_SECOND', + 'STORAGE_BYTE_MONTH', +]) +export const notificationTone = pgEnum('notification_tone', ['INFO', 'SUCCESS', 'WARNING', 'ERROR']) +export const publishingChannelStatus = pgEnum('publishing_channel_status', ['CONNECTED', 'EXPIRED', 'REVOKED', 'ERROR']) + +export const users = pgTable('users', { + id: uuid('id').defaultRandom().primaryKey(), + email: varchar('email', { length: 320 }).notNull(), + displayName: varchar('display_name', { length: 120 }).notNull(), + passwordHash: text('password_hash').notNull(), + avatarKey: text('avatar_key'), + emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), + disabledAt: timestamp('disabled_at', { withTimezone: true }), + ...timestamps, +}, (table) => [uniqueIndex('users_email_unique').on(table.email)]) + +export const workspaces = pgTable('workspaces', { + id: uuid('id').defaultRandom().primaryKey(), + name: varchar('name', { length: 160 }).notNull(), + slug: varchar('slug', { length: 80 }).notNull(), + ownerId: uuid('owner_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + plan: varchar('plan', { length: 32 }).default('PRO').notNull(), + monthlyCreditLimit: integer('monthly_credit_limit').default(10000).notNull(), + settings: jsonb('settings').$type>().default({}).notNull(), + ...timestamps, +}, (table) => [uniqueIndex('workspaces_slug_unique').on(table.slug), index('workspaces_owner_idx').on(table.ownerId)]) + +export const billingSubscriptions = pgTable('billing_subscriptions', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + provider: varchar('provider', { length: 32 }).default('stripe').notNull(), + customerId: varchar('customer_id', { length: 255 }).notNull(), + subscriptionId: varchar('subscription_id', { length: 255 }), + priceId: varchar('price_id', { length: 255 }), + plan: varchar('plan', { length: 32 }), + status: varchar('status', { length: 48 }).default('no_subscription').notNull(), + currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }), + cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false).notNull(), + lastEventCreatedAt: timestamp('last_event_created_at', { withTimezone: true }), + ...timestamps, +}, (table) => [ + uniqueIndex('billing_subscriptions_workspace_unique').on(table.workspaceId), + uniqueIndex('billing_subscriptions_customer_unique').on(table.customerId), + uniqueIndex('billing_subscriptions_subscription_unique').on(table.subscriptionId), + index('billing_subscriptions_status_idx').on(table.status), +]) + +export const billingWebhookEvents = pgTable('billing_webhook_events', { + eventId: varchar('event_id', { length: 255 }).primaryKey(), + provider: varchar('provider', { length: 32 }).default('stripe').notNull(), + eventType: varchar('event_type', { length: 160 }).notNull(), + livemode: boolean('livemode').default(false).notNull(), + workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'set null' }), + providerCreatedAt: timestamp('provider_created_at', { withTimezone: true }).notNull(), + processedAt: timestamp('processed_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + index('billing_webhook_events_workspace_idx').on(table.workspaceId, table.processedAt), + index('billing_webhook_events_type_idx').on(table.eventType), +]) + +export const memberships = pgTable('memberships', { + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + role: membershipRole('role').default('VIEWER').notNull(), + invitedById: uuid('invited_by_id').references(() => users.id, { onDelete: 'set null' }), + joinedAt: timestamp('joined_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + primaryKey({ columns: [table.workspaceId, table.userId] }), + index('memberships_user_idx').on(table.userId), +]) + +export const providerVerifications = pgTable('provider_verifications', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + provider: varchar('provider', { length: 32 }).$type<'openai' | 'replicate' | 'agnes' | 'generic'>().notNull(), + status: varchar('status', { length: 32 }).$type<'VERIFIED' | 'FAILED'>().notNull(), + configurationFingerprint: varchar('configuration_fingerprint', { length: 64 }).notNull(), + checkedAt: timestamp('checked_at', { withTimezone: true }).defaultNow().notNull(), + latencyMs: integer('latency_ms'), + errorCode: varchar('error_code', { length: 80 }), + message: text('message'), + details: jsonb('details').$type>().default({}).notNull(), + checkedById: uuid('checked_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [ + uniqueIndex('provider_verifications_workspace_provider_unique').on(table.workspaceId, table.provider), + index('provider_verifications_workspace_status_idx').on(table.workspaceId, table.status), +]) + +export const workspaceInvitations = pgTable('workspace_invitations', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + email: varchar('email', { length: 320 }).notNull(), + role: membershipRole('role').default('VIEWER').notNull(), + tokenHash: text('token_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + acceptedAt: timestamp('accepted_at', { withTimezone: true }), + acceptedById: uuid('accepted_by_id').references(() => users.id, { onDelete: 'set null' }), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + invitedById: uuid('invited_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [ + uniqueIndex('workspace_invitations_token_hash_unique').on(table.tokenHash), + index('workspace_invitations_workspace_email_idx').on(table.workspaceId, table.email), + index('workspace_invitations_workspace_created_idx').on(table.workspaceId, table.createdAt), +]) + +export const refreshTokens = pgTable('refresh_tokens', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + revokedAt: timestamp('revoked_at', { withTimezone: true }), + replacedById: uuid('replaced_by_id'), + userAgent: text('user_agent'), + ipAddress: varchar('ip_address', { length: 64 }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [uniqueIndex('refresh_tokens_hash_unique').on(table.tokenHash), index('refresh_tokens_user_idx').on(table.userId)]) + +export const accountActionTokens = pgTable('account_action_tokens', { + id: uuid('id').defaultRandom().primaryKey(), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + type: accountTokenType('type').notNull(), + tokenHash: text('token_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + consumedAt: timestamp('consumed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + uniqueIndex('account_action_tokens_hash_unique').on(table.tokenHash), + index('account_action_tokens_user_type_idx').on(table.userId, table.type), + index('account_action_tokens_expiry_idx').on(table.expiresAt), +]) + +export const projects = pgTable('projects', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + name: varchar('name', { length: 180 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull(), + logline: text('logline').default('').notNull(), + genre: varchar('genre', { length: 80 }).default('都市悬疑').notNull(), + visualBible: jsonb('visual_bible').$type>().default({}).notNull(), + status: projectStatus('status').default('DRAFT').notNull(), + ...timestamps, +}, (table) => [ + uniqueIndex('projects_workspace_slug_unique').on(table.workspaceId, table.slug), + index('projects_workspace_idx').on(table.workspaceId), +]) + +export const episodes = pgTable('episodes', { + id: uuid('id').defaultRandom().primaryKey(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + episodeNumber: integer('episode_number').notNull(), + title: varchar('title', { length: 200 }).notNull(), + synopsis: text('synopsis').default('').notNull(), + targetDurationSeconds: integer('target_duration_seconds').default(180).notNull(), + status: episodeStatus('status').default('OUTLINE').notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [ + uniqueIndex('episodes_project_number_unique').on(table.projectId, table.episodeNumber), + index('episodes_project_idx').on(table.projectId), +]) + +export const scriptVersions = pgTable('script_versions', { + id: uuid('id').defaultRandom().primaryKey(), + episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + title: varchar('title', { length: 200 }).notNull(), + content: jsonb('content').$type>().notNull(), + plainText: text('plain_text').default('').notNull(), + changeSummary: text('change_summary').default('').notNull(), + isCurrent: boolean('is_current').default(false).notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + uniqueIndex('script_versions_episode_version_unique').on(table.episodeId, table.version), + index('script_versions_episode_current_idx').on(table.episodeId, table.isCurrent), +]) + +export const assets = pgTable('assets', { + id: uuid('id').defaultRandom().primaryKey(), + projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }), + type: assetType('type').notNull(), + name: varchar('name', { length: 180 }).notNull(), + description: text('description').default('').notNull(), + status: assetStatus('status').default('DRAFT').notNull(), + tags: jsonb('tags').$type().default([]).notNull(), + metadata: jsonb('metadata').$type>().default({}).notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [index('assets_project_type_idx').on(table.projectId, table.type), index('assets_project_status_idx').on(table.projectId, table.status)]) + +export const assetVersions = pgTable('asset_versions', { + id: uuid('id').defaultRandom().primaryKey(), + assetId: uuid('asset_id').notNull().references(() => assets.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + storageKey: text('storage_key'), + thumbnailKey: text('thumbnail_key'), + mimeType: varchar('mime_type', { length: 120 }), + byteSize: integer('byte_size'), + width: integer('width'), + height: integer('height'), + durationMs: integer('duration_ms'), + prompt: text('prompt').default('').notNull(), + negativePrompt: text('negative_prompt').default('').notNull(), + provider: varchar('provider', { length: 80 }), + providerModel: varchar('provider_model', { length: 160 }), + seed: varchar('seed', { length: 80 }), + generationParams: jsonb('generation_params').$type>().default({}).notNull(), + isCurrent: boolean('is_current').default(false).notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + uniqueIndex('asset_versions_asset_version_unique').on(table.assetId, table.version), + index('asset_versions_current_idx').on(table.assetId, table.isCurrent), +]) + +export const shots = pgTable('shots', { + id: uuid('id').defaultRandom().primaryKey(), + episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }), + shotNumber: integer('shot_number').notNull(), + sceneNumber: integer('scene_number').notNull(), + title: varchar('title', { length: 180 }).notNull(), + description: text('description').default('').notNull(), + shotType: varchar('shot_type', { length: 50 }).default('中景').notNull(), + cameraMotion: varchar('camera_motion', { length: 50 }).default('静止').notNull(), + durationMs: integer('duration_ms').default(3000).notNull(), + status: shotStatus('status').default('DRAFT').notNull(), + characterAssetIds: jsonb('character_asset_ids').$type().default([]).notNull(), + sceneAssetId: uuid('scene_asset_id').references(() => assets.id, { onDelete: 'set null' }), + sortOrder: integer('sort_order').notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [ + uniqueIndex('shots_episode_number_unique').on(table.episodeId, table.shotNumber), + index('shots_episode_sort_idx').on(table.episodeId, table.sortOrder), +]) + +export const shotVersions = pgTable('shot_versions', { + id: uuid('id').defaultRandom().primaryKey(), + shotId: uuid('shot_id').notNull().references(() => shots.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + imageAssetVersionId: uuid('image_asset_version_id').references(() => assetVersions.id, { onDelete: 'set null' }), + videoStorageKey: text('video_storage_key'), + audioStorageKey: text('audio_storage_key'), + subtitleText: text('subtitle_text').default('').notNull(), + generationParams: jsonb('generation_params').$type>().default({}).notNull(), + isCurrent: boolean('is_current').default(false).notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [uniqueIndex('shot_versions_shot_version_unique').on(table.shotId, table.version), index('shot_versions_current_idx').on(table.shotId, table.isCurrent)]) + +export interface RenderSourceManifest { + schemaVersion: 1 + capturedAt: string + shots: Array<{ + shotId: string + shotVersionId: string + shotNumber: number + shotVersion: number + sortOrder: number + durationMs: number + subtitleText: string + imageAssetVersionId: string | null + }> +} + +export const renders = pgTable('renders', { + id: uuid('id').defaultRandom().primaryKey(), + episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + status: renderStatus('status').default('QUEUED').notNull(), + storageKey: text('storage_key'), + thumbnailKey: text('thumbnail_key'), + subtitleSrtKey: text('subtitle_srt_key'), + subtitleVttKey: text('subtitle_vtt_key'), + mimeType: varchar('mime_type', { length: 120 }).default('video/mp4').notNull(), + byteSize: integer('byte_size'), + durationMs: integer('duration_ms'), + width: integer('width').notNull(), + height: integer('height').notNull(), + fps: integer('fps').default(24).notNull(), + settings: jsonb('settings').$type>().default({}).notNull(), + sourceManifest: jsonb('source_manifest').$type(), + qualityReport: jsonb('quality_report').$type>(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + ...timestamps, +}, (table) => [uniqueIndex('renders_episode_version_unique').on(table.episodeId, table.version), index('renders_episode_status_idx').on(table.episodeId, table.status)]) + +export const publishingChannelAccounts = pgTable('publishing_channel_accounts', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + platform: varchar('platform', { length: 32 }).notNull(), + externalAccountId: varchar('external_account_id', { length: 255 }).notNull(), + displayName: varchar('display_name', { length: 180 }).notNull(), + avatarUrl: text('avatar_url'), + status: publishingChannelStatus('status').default('CONNECTED').notNull(), + accessTokenCiphertext: text('access_token_ciphertext'), + refreshTokenCiphertext: text('refresh_token_ciphertext'), + tokenExpiresAt: timestamp('token_expires_at', { withTimezone: true }), + scopes: jsonb('scopes').$type().default([]).notNull(), + metadata: jsonb('metadata').$type>().default({}).notNull(), + connectedById: uuid('connected_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + lastVerifiedAt: timestamp('last_verified_at', { withTimezone: true }).defaultNow().notNull(), + errorCode: varchar('error_code', { length: 100 }), + errorMessage: text('error_message'), + ...timestamps, +}, (table) => [ + uniqueIndex('publishing_channel_accounts_external_unique').on(table.workspaceId, table.platform, table.externalAccountId), + index('publishing_channel_accounts_workspace_idx').on(table.workspaceId, table.platform), + index('publishing_channel_accounts_status_idx').on(table.workspaceId, table.status), +]) + +export const publishingOauthStates = pgTable('publishing_oauth_states', { + stateHash: varchar('state_hash', { length: 64 }).primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + platform: varchar('platform', { length: 32 }).notNull(), + codeVerifierCiphertext: text('code_verifier_ciphertext'), + returnPath: text('return_path').default('/?publishing=connected').notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + consumedAt: timestamp('consumed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + index('publishing_oauth_states_expiry_idx').on(table.expiresAt), + index('publishing_oauth_states_workspace_idx').on(table.workspaceId, table.platform), +]) + +export const productionRuns = pgTable('production_runs', { + id: uuid('id').defaultRandom().primaryKey(), + episodeId: uuid('episode_id').notNull().references(() => episodes.id, { onDelete: 'cascade' }), + renderId: uuid('render_id').notNull().references(() => renders.id, { onDelete: 'cascade' }), + status: productionRunStatus('status').default('QUEUED').notNull(), + settings: jsonb('settings').$type>().default({}).notNull(), + totalJobs: integer('total_jobs').notNull(), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + startedAt: timestamp('started_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + ...timestamps, +}, (table) => [ + uniqueIndex('production_runs_render_unique').on(table.renderId), + index('production_runs_episode_status_idx').on(table.episodeId, table.status), + index('production_runs_episode_created_idx').on(table.episodeId, table.createdAt), +]) + +export const generationJobs = pgTable('generation_jobs', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }), + episodeId: uuid('episode_id').references(() => episodes.id, { onDelete: 'cascade' }), + productionRunId: uuid('production_run_id').references(() => productionRuns.id, { onDelete: 'set null' }), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + type: jobType('type').notNull(), + status: jobStatus('status').default('QUEUED').notNull(), + progress: integer('progress').default(0).notNull(), + provider: varchar('provider', { length: 80 }), + providerModel: varchar('provider_model', { length: 160 }), + externalId: varchar('external_id', { length: 255 }), + idempotencyKey: varchar('idempotency_key', { length: 180 }).notNull(), + input: jsonb('input').$type>().notNull(), + output: jsonb('output').$type>(), + errorCode: varchar('error_code', { length: 100 }), + errorMessage: text('error_message'), + attempts: integer('attempts').default(0).notNull(), + maxAttempts: integer('max_attempts').default(3).notNull(), + estimatedCredits: numeric('estimated_credits', { precision: 20, scale: 4 }).default('0').notNull(), + startedAt: timestamp('started_at', { withTimezone: true }), + completedAt: timestamp('completed_at', { withTimezone: true }), + ...timestamps, +}, (table) => [ + uniqueIndex('generation_jobs_idempotency_unique').on(table.workspaceId, table.idempotencyKey), + index('generation_jobs_workspace_status_idx').on(table.workspaceId, table.status), + index('generation_jobs_project_idx').on(table.projectId), + index('generation_jobs_production_run_idx').on(table.productionRunId), +]) + +export const reviewRequests = pgTable('review_requests', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + targetType: reviewTargetType('target_type').notNull(), + targetId: uuid('target_id').notNull(), + status: reviewStatus('status').default('OPEN').notNull(), + requestedById: uuid('requested_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + assignedToId: uuid('assigned_to_id').references(() => users.id, { onDelete: 'set null' }), + resolvedById: uuid('resolved_by_id').references(() => users.id, { onDelete: 'set null' }), + summary: text('summary').default('').notNull(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + ...timestamps, +}, (table) => [index('review_requests_workspace_status_idx').on(table.workspaceId, table.status), index('review_requests_target_idx').on(table.targetType, table.targetId)]) + +export const reviewComments = pgTable('review_comments', { + id: uuid('id').defaultRandom().primaryKey(), + reviewRequestId: uuid('review_request_id').notNull().references(() => reviewRequests.id, { onDelete: 'cascade' }), + authorId: uuid('author_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + body: text('body').notNull(), + timecodeMs: integer('timecode_ms'), + metadata: jsonb('metadata').$type>().default({}).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [index('review_comments_request_idx').on(table.reviewRequestId)]) + +export const deliveries = pgTable('deliveries', { + id: uuid('id').defaultRandom().primaryKey(), + renderId: uuid('render_id').notNull().references(() => renders.id, { onDelete: 'cascade' }), + jobId: uuid('job_id').references(() => generationJobs.id, { onDelete: 'set null' }), + channelAccountId: uuid('channel_account_id').references(() => publishingChannelAccounts.id, { onDelete: 'cascade' }), + platform: varchar('platform', { length: 80 }).notNull(), + status: deliveryStatus('status').default('DRAFT').notNull(), + destination: jsonb('destination').$type>().default({}).notNull(), + externalId: varchar('external_id', { length: 255 }), + publishedUrl: text('published_url'), + scheduledAt: timestamp('scheduled_at', { withTimezone: true }), + publishedAt: timestamp('published_at', { withTimezone: true }), + lastProviderEventAt: timestamp('last_provider_event_at', { withTimezone: true }), + createdById: uuid('created_by_id').notNull().references(() => users.id, { onDelete: 'restrict' }), + errorMessage: text('error_message'), + ...timestamps, +}, (table) => [ + index('deliveries_render_idx').on(table.renderId), + index('deliveries_job_idx').on(table.jobId), + index('deliveries_channel_account_idx').on(table.channelAccountId), + index('deliveries_status_idx').on(table.status), +]) + +export const publishingWebhookEvents = pgTable('publishing_webhook_events', { + eventKey: varchar('event_key', { length: 320 }).primaryKey(), + eventId: varchar('event_id', { length: 255 }).notNull(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + platform: varchar('platform', { length: 32 }).notNull(), + deliveryId: uuid('delivery_id').references(() => deliveries.id, { onDelete: 'set null' }), + status: varchar('status', { length: 32 }).notNull(), + providerOccurredAt: timestamp('provider_occurred_at', { withTimezone: true }).notNull(), + payload: jsonb('payload').$type>().default({}).notNull(), + applied: boolean('applied').default(false).notNull(), + processedAt: timestamp('processed_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [ + index('publishing_webhook_events_delivery_idx').on(table.deliveryId, table.providerOccurredAt), + index('publishing_webhook_events_workspace_idx').on(table.workspaceId, table.processedAt), + index('publishing_webhook_events_platform_idx').on(table.platform, table.processedAt), +]) + +export const usageLedger = pgTable('usage_ledger', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), + jobId: uuid('job_id').references(() => generationJobs.id, { onDelete: 'set null' }), + kind: usageKind('kind').notNull(), + quantity: numeric('quantity', { precision: 20, scale: 4 }).notNull(), + credits: numeric('credits', { precision: 20, scale: 4 }).notNull(), + providerCostUsd: numeric('provider_cost_usd', { precision: 20, scale: 6 }), + metadata: jsonb('metadata').$type>().default({}).notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [index('usage_ledger_workspace_time_idx').on(table.workspaceId, table.occurredAt), index('usage_ledger_job_idx').on(table.jobId)]) + +export const notifications = pgTable('notifications', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').notNull().references(() => workspaces.id, { onDelete: 'cascade' }), + userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + kind: varchar('kind', { length: 80 }).notNull(), + tone: notificationTone('tone').default('INFO').notNull(), + title: varchar('title', { length: 240 }).notNull(), + body: text('body').default('').notNull(), + page: varchar('page', { length: 48 }).notNull(), + resourceType: varchar('resource_type', { length: 80 }), + resourceId: uuid('resource_id'), + dedupeKey: varchar('dedupe_key', { length: 255 }).notNull(), + metadata: jsonb('metadata').$type>().default({}).notNull(), + readAt: timestamp('read_at', { withTimezone: true }), + ...timestamps, +}, (table) => [ + uniqueIndex('notifications_user_dedupe_unique').on(table.userId, table.dedupeKey), + index('notifications_user_created_idx').on(table.userId, table.createdAt), + index('notifications_user_unread_idx').on(table.userId, table.readAt, table.createdAt), + index('notifications_workspace_created_idx').on(table.workspaceId, table.createdAt), +]) + +export const auditLogs = pgTable('audit_logs', { + id: uuid('id').defaultRandom().primaryKey(), + workspaceId: uuid('workspace_id').references(() => workspaces.id, { onDelete: 'cascade' }), + actorId: uuid('actor_id').references(() => users.id, { onDelete: 'set null' }), + action: varchar('action', { length: 120 }).notNull(), + resourceType: varchar('resource_type', { length: 80 }).notNull(), + resourceId: uuid('resource_id'), + ipAddress: varchar('ip_address', { length: 64 }), + userAgent: text('user_agent'), + before: jsonb('before').$type>(), + after: jsonb('after').$type>(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), +}, (table) => [index('audit_logs_workspace_time_idx').on(table.workspaceId, table.createdAt), index('audit_logs_actor_idx').on(table.actorId)]) + +export type User = typeof users.$inferSelect +export type Workspace = typeof workspaces.$inferSelect +export type Project = typeof projects.$inferSelect +export type Episode = typeof episodes.$inferSelect +export type Asset = typeof assets.$inferSelect +export type GenerationJob = typeof generationJobs.$inferSelect +export type ProductionRun = typeof productionRuns.$inferSelect +export type Notification = typeof notifications.$inferSelect +export type ProviderVerification = typeof providerVerifications.$inferSelect diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts new file mode 100644 index 0000000..351b529 --- /dev/null +++ b/server/src/db/seed.ts @@ -0,0 +1,269 @@ +import argon2 from 'argon2' +import { readFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { and, desc, eq, sql } from 'drizzle-orm' +import { closeDatabase, db } from './client.js' +import { createCharacterConsistencyProfile, readCharacterConsistencyProfile } from '../lib/character-consistency.js' +import { closeStorage, projectSeedObjectKey, putObject } from '../storage/client.js' +import { + assetVersions, + assets, + episodes, + memberships, + projects, + scriptVersions, + shotVersions, + shots, + users, + workspaces, +} from './schema.js' + +const seedEmail = 'studio@frameflow.local' + +async function seed() { + const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..') + const passwordHash = await argon2.hash('FrameFlow2026!', { type: argon2.argon2id }) + + const [user] = await db.insert(users).values({ + email: seedEmail, + displayName: '雾灯工作室', + passwordHash, + emailVerifiedAt: new Date(), + }).onConflictDoUpdate({ + target: users.email, + set: { displayName: '雾灯工作室', passwordHash, updatedAt: new Date() }, + }).returning() + + if (!user) throw new Error('Failed to create seed user') + + let [workspace] = await db.select().from(workspaces).where(eq(workspaces.slug, 'mistlight-studio')).limit(1) + if (!workspace) { + ;[workspace] = await db.insert(workspaces).values({ + name: '雾灯工作室', + slug: 'mistlight-studio', + ownerId: user.id, + }).returning() + } + if (!workspace) throw new Error('Failed to create seed workspace') + + await db.insert(memberships).values({ + workspaceId: workspace.id, + userId: user.id, + role: 'OWNER', + }).onConflictDoUpdate({ + target: [memberships.workspaceId, memberships.userId], + set: { role: 'OWNER', updatedAt: new Date() }, + }) + + let [project] = await db.select().from(projects).where(and(eq(projects.workspaceId, workspace.id), eq(projects.slug, 'mist-letters'))).limit(1) + if (!project) { + ;[project] = await db.insert(projects).values({ + workspaceId: workspace.id, + createdById: user.id, + name: '雾城来信', + slug: 'mist-letters', + logline: '调查记者循着匿名录音,揭开十年前失踪案与整座雾城的秘密。', + genre: '都市悬疑', + status: 'ACTIVE', + visualBible: { + style: '写实电影感漫画', + palette: ['冷灰', '雾蓝', '信号红'], + lighting: '潮湿雨夜、硬侧光、克制霓虹', + }, + }).returning() + } + if (!project) throw new Error('Failed to create seed project') + + let [episode] = await db.select().from(episodes).where(and(eq(episodes.projectId, project.id), eq(episodes.episodeNumber, 4))).limit(1) + if (!episode) { + ;[episode] = await db.insert(episodes).values({ + projectId: project.id, + episodeNumber: 4, + title: '暗房里的第二封信', + synopsis: '林遥循着录音中的钟声来到旧城天台,与陆澄第一次正面谈起十年前的失踪案。', + status: 'STORYBOARDING', + targetDurationSeconds: 190, + createdById: user.id, + }).returning() + } + if (!episode) throw new Error('Failed to create seed episode') + + const [existingScript] = await db.select().from(scriptVersions).where(and(eq(scriptVersions.episodeId, episode.id), eq(scriptVersions.version, 1))).limit(1) + if (!existingScript) { + await db.insert(scriptVersions).values({ + episodeId: episode.id, + version: 1, + title: episode.title, + isCurrent: true, + createdById: user.id, + changeSummary: '初始剧本', + plainText: '场 12 · 外 · 旧城天台 · 夜\n林遥:你知道我会来。\n陆澄:有些答案只能在下雨的时候被听见。', + content: { + scenes: [ + { + number: 12, + location: '旧城天台', + time: '夜', + beats: [ + { type: 'action', text: '雨落得很急,铁门在风里撞向墙面。' }, + { type: 'dialogue', character: '林遥', text: '你知道我会来。' }, + { type: 'dialogue', character: '陆澄', text: '有些答案只能在下雨的时候被听见。' }, + ], + }, + ], + }, + }) + } + + const assetSeeds = [ + { + name: '林遥 · 侦探记者', + type: 'CHARACTER' as const, + description: '二十七岁调查记者,短发,克制而敏锐。', + filename: 'character-linyao.jpg', + identityPrompt: '二十七岁东亚女性调查记者,利落短发,克制敏锐的眼神,清晰稳定的面部轮廓。', + costumePrompt: '深色短款记者外套,简洁内搭,不佩戴夸张饰品。', + }, + { + name: '陆澄 · 法医顾问', + type: 'CHARACTER' as const, + description: '三十二岁法医顾问,冷静,带有难以辨认的疲惫。', + filename: 'character-lucheng.jpg', + identityPrompt: '三十二岁东亚男性法医顾问,短发,冷静疲惫的眼神,清晰稳定的面部轮廓。', + costumePrompt: '深色衬衫与克制的职业外套,无醒目标识。', + }, + { + name: '旧城天台 · 雨夜', + type: 'SCENE' as const, + description: '暴雨中的旧城高楼天台,雾与霓虹交叠。', + filename: 'scene-rooftop.jpg', + }, + ] + + const seededAssets = new Map() + for (const seedAsset of assetSeeds) { + const body = await readFile(resolve(workspaceRoot, 'public/assets', seedAsset.filename)) + const key = projectSeedObjectKey(workspace.id, project.id, seedAsset.filename) + await putObject({ + key, + body, + contentType: 'image/jpeg', + metadata: { source: 'seed', projectId: project.id }, + }) + let [asset] = await db.select().from(assets).where(and(eq(assets.projectId, project.id), eq(assets.name, seedAsset.name))).limit(1) + if (!asset) { + ;[asset] = await db.insert(assets).values({ + projectId: project.id, + type: seedAsset.type, + name: seedAsset.name, + description: seedAsset.description, + status: 'APPROVED', + tags: seedAsset.type === 'CHARACTER' ? ['主角'] : ['外景', '夜'], + createdById: user.id, + }).returning() + } + if (!asset) continue + let [version] = await db.select().from(assetVersions).where(and(eq(assetVersions.assetId, asset.id), eq(assetVersions.version, 1))).limit(1) + if (!version) { + ;[version] = await db.insert(assetVersions).values({ + assetId: asset.id, + version: 1, + storageKey: key, + mimeType: 'image/jpeg', + byteSize: body.length, + prompt: seedAsset.description, + provider: 'local-seed', + providerModel: 'bundled-example-v1', + generationParams: { source: 'seed', filename: seedAsset.filename }, + isCurrent: true, + createdById: user.id, + }).returning() + } else if (version.storageKey === `seed/${seedAsset.filename}` || version.storageKey === key) { + ;[version] = await db.update(assetVersions).set({ + storageKey: key, + mimeType: 'image/jpeg', + byteSize: body.length, + provider: 'local-seed', + providerModel: 'bundled-example-v1', + generationParams: { ...(version.generationParams ?? {}), source: 'seed', filename: seedAsset.filename }, + }).where(eq(assetVersions.id, version.id)).returning() + } + if (!version) throw new Error(`Failed to create seed version for ${seedAsset.name}`) + + if (seedAsset.type === 'CHARACTER' && !readCharacterConsistencyProfile(asset.metadata)) { + const consistencyProfile = createCharacterConsistencyProfile({ + referenceAssetVersionIds: [version.id], + identityPrompt: seedAsset.identityPrompt, + costumePrompt: seedAsset.costumePrompt, + stylePrompt: '写实电影感漫画,冷灰雾蓝色调,潮湿雨夜硬侧光。', + negativePrompt: '身份漂移,年龄变化,发型变化,畸形五官,文字,水印', + consistencyStrength: 90, + identityLocked: true, + }, user.id) + ;[asset] = await db.update(assets).set({ + metadata: { ...(asset.metadata ?? {}), consistencyProfile }, + updatedAt: new Date(), + }).where(eq(assets.id, asset.id)).returning() + if (!asset) throw new Error(`Failed to create seed consistency profile for ${seedAsset.name}`) + } + seededAssets.set(seedAsset.name, { asset, version }) + } + + const linyao = seededAssets.get('林遥 · 侦探记者') + const lucheng = seededAssets.get('陆澄 · 法医顾问') + const rooftop = seededAssets.get('旧城天台 · 雨夜') + if (!linyao || !lucheng || !rooftop) throw new Error('Seed asset references are incomplete') + + let [existingShot] = await db.select().from(shots).where(and(eq(shots.episodeId, episode.id), eq(shots.shotNumber, 1))).limit(1) + if (!existingShot) { + ;[existingShot] = await db.insert(shots).values({ + episodeId: episode.id, + shotNumber: 1, + sceneNumber: 12, + sortOrder: 100, + title: '雨夜旧城天台', + description: '暴雨掠过旧城天台,霓虹在积水中摇晃。', + shotType: '全景', + durationMs: 3200, + status: 'APPROVED', + characterAssetIds: [linyao.asset.id, lucheng.asset.id], + sceneAssetId: rooftop.asset.id, + createdById: user.id, + }).returning() + } else if (existingShot.characterAssetIds.length === 0 && !existingShot.sceneAssetId) { + ;[existingShot] = await db.update(shots).set({ + characterAssetIds: [linyao.asset.id, lucheng.asset.id], + sceneAssetId: rooftop.asset.id, + updatedAt: new Date(), + }).where(eq(shots.id, existingShot.id)).returning() + } + if (!existingShot) throw new Error('Failed to create seed shot') + + const existingShotVersions = await db.select().from(shotVersions).where(eq(shotVersions.shotId, existingShot.id)).orderBy(desc(shotVersions.version)) + if (!existingShotVersions.some((version) => version.generationParams?.source === 'seed')) { + await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${existingShot.id}`}))`) + const [latest] = await tx.select({ version: shotVersions.version }).from(shotVersions).where(eq(shotVersions.shotId, existingShot.id)).orderBy(desc(shotVersions.version)).limit(1) + await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, existingShot.id)) + await tx.insert(shotVersions).values({ + shotId: existingShot.id, + version: (latest?.version ?? 0) + 1, + imageAssetVersionId: rooftop.version.id, + subtitleText: '林遥:你知道我会来。\n陆澄:有些答案只能在下雨的时候被听见。', + generationParams: { source: 'seed', operation: 'example-storyboard' }, + isCurrent: true, + createdById: user.id, + }) + }) + } + + console.info(`Seed complete. Login: ${seedEmail} / FrameFlow2026!`) +} + +try { + await seed() +} finally { + await closeDatabase() + closeStorage() +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..489943c --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,23 @@ +import { buildApp } from './app.js' +import { config } from './config.js' +import { closeDatabase } from './db/client.js' + +const app = await buildApp() + +async function shutdown(signal: string) { + app.log.info({ signal }, 'Shutting down') + await app.close() + await closeDatabase() + process.exit(0) +} + +process.on('SIGINT', () => void shutdown('SIGINT')) +process.on('SIGTERM', () => void shutdown('SIGTERM')) + +try { + await app.listen({ host: config.API_HOST, port: config.API_PORT }) +} catch (error) { + app.log.error(error) + await closeDatabase() + process.exit(1) +} diff --git a/server/src/lib/account-tokens.ts b/server/src/lib/account-tokens.ts new file mode 100644 index 0000000..a3ee759 --- /dev/null +++ b/server/src/lib/account-tokens.ts @@ -0,0 +1,40 @@ +import { randomBytes } from 'node:crypto' +import { and, eq, isNull, sql } from 'drizzle-orm' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { accountActionTokens } from '../db/schema.js' +import { hashToken } from './auth.js' + +export type AccountTokenType = 'EMAIL_VERIFICATION' | 'PASSWORD_RESET' + +function tokenTtlMs(type: AccountTokenType) { + return type === 'EMAIL_VERIFICATION' + ? config.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1000 + : config.PASSWORD_RESET_TTL_MINUTES * 60 * 1000 +} + +export async function issueAccountToken(userId: string, type: AccountTokenType) { + const token = randomBytes(32).toString('base64url') + const tokenHash = hashToken(token) + const now = new Date() + const expiresAt = new Date(now.getTime() + tokenTtlMs(type)) + + const record = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`account-token:${userId}:${type}`}))`) + await tx.update(accountActionTokens).set({ consumedAt: now }).where(and( + eq(accountActionTokens.userId, userId), + eq(accountActionTokens.type, type), + isNull(accountActionTokens.consumedAt), + )) + const [created] = await tx.insert(accountActionTokens).values({ + userId, + type, + tokenHash, + expiresAt, + }).returning() + if (!created) throw new Error('Failed to create account action token') + return created + }) + + return { token, record } +} diff --git a/server/src/lib/audit.ts b/server/src/lib/audit.ts new file mode 100644 index 0000000..2791e27 --- /dev/null +++ b/server/src/lib/audit.ts @@ -0,0 +1,27 @@ +import type { FastifyRequest } from 'fastify' +import { db } from '../db/client.js' +import { auditLogs } from '../db/schema.js' +import { requestMeta } from './http.js' + +export async function audit(input: { + request: FastifyRequest + workspaceId?: string + action: string + resourceType: string + resourceId?: string + before?: Record + after?: Record +}) { + const meta = requestMeta(input.request) + await db.insert(auditLogs).values({ + workspaceId: input.workspaceId, + actorId: input.request.user?.sub, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId, + before: input.before, + after: input.after, + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }) +} diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts new file mode 100644 index 0000000..b0e84c0 --- /dev/null +++ b/server/src/lib/auth.ts @@ -0,0 +1,100 @@ +import { createHash, randomBytes } from 'node:crypto' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import { and, eq, gt, isNull } from 'drizzle-orm' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { refreshTokens, users } from '../db/schema.js' +import { errors } from './errors.js' + +export const REFRESH_COOKIE = 'ff_refresh' + +export function hashToken(value: string) { + return createHash('sha256').update(value).digest('hex') +} + +export function generateRefreshToken() { + return randomBytes(48).toString('base64url') +} + +export function refreshCookieOptions() { + return { + httpOnly: true, + secure: config.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/api/v1/auth', + maxAge: config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60, + } +} + +export function signAccessToken(app: FastifyInstance, user: { id: string; email: string; displayName: string }) { + return app.jwt.sign({ + sub: user.id, + email: user.email, + displayName: user.displayName, + tokenType: 'access' as const, + }, { expiresIn: config.ACCESS_TOKEN_TTL }) +} + +export async function createRefreshSession(input: { + userId: string + userAgent?: string + ipAddress?: string +}) { + const token = generateRefreshToken() + const expiresAt = new Date(Date.now() + config.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000) + const [session] = await db.insert(refreshTokens).values({ + userId: input.userId, + tokenHash: hashToken(token), + expiresAt, + userAgent: input.userAgent, + ipAddress: input.ipAddress, + }).returning() + if (!session) throw new Error('Failed to create refresh session') + return { token, session } +} + +export async function rotateRefreshSession(token: string, meta: { userAgent?: string; ipAddress?: string }) { + const [current] = await db.select().from(refreshTokens).where(and( + eq(refreshTokens.tokenHash, hashToken(token)), + isNull(refreshTokens.revokedAt), + gt(refreshTokens.expiresAt, new Date()), + )).limit(1) + + if (!current) throw errors.unauthorized('Refresh session is invalid or expired') + + const [user] = await db.select({ + id: users.id, + email: users.email, + displayName: users.displayName, + emailVerifiedAt: users.emailVerifiedAt, + disabledAt: users.disabledAt, + }).from(users).where(eq(users.id, current.userId)).limit(1) + + if (!user || user.disabledAt) throw errors.unauthorized('Account is unavailable') + if (config.EMAIL_VERIFICATION_REQUIRED && !user.emailVerifiedAt) throw errors.emailVerificationRequired() + + const next = await createRefreshSession({ userId: user.id, ...meta }) + await db.update(refreshTokens).set({ + revokedAt: new Date(), + replacedById: next.session.id, + }).where(eq(refreshTokens.id, current.id)) + + return { user, token: next.token } +} + +export async function revokeRefreshSession(token?: string) { + if (!token) return + await db.update(refreshTokens).set({ revokedAt: new Date() }).where(and( + eq(refreshTokens.tokenHash, hashToken(token)), + isNull(refreshTokens.revokedAt), + )) +} + +export async function requireAuth(request: FastifyRequest, _reply: FastifyReply) { + try { + await request.jwtVerify() + } catch { + throw errors.unauthorized() + } + if (request.user.tokenType !== 'access') throw errors.unauthorized() +} diff --git a/server/src/lib/billing-plans.ts b/server/src/lib/billing-plans.ts new file mode 100644 index 0000000..43c376b --- /dev/null +++ b/server/src/lib/billing-plans.ts @@ -0,0 +1,18 @@ +export const BILLING_PLAN_CATALOG = [ + { id: 'PRO', monthlyCreditLimit: 10_000, purchasable: true }, + { id: 'STUDIO', monthlyCreditLimit: 50_000, purchasable: true }, + { id: 'ENTERPRISE', monthlyCreditLimit: null, purchasable: false }, +] as const + +export type BillingPlanId = typeof BILLING_PLAN_CATALOG[number]['id'] +export type PurchasableBillingPlan = Extract + +export const DEFAULT_BILLING_PLAN: BillingPlanId = 'PRO' + +export function billingPlan(plan: string | null | undefined) { + return BILLING_PLAN_CATALOG.find((item) => item.id === plan) ?? null +} + +export function planCreditLimit(plan: BillingPlanId) { + return billingPlan(plan)?.monthlyCreditLimit ?? 10_000 +} diff --git a/server/src/lib/billing.ts b/server/src/lib/billing.ts new file mode 100644 index 0000000..60f194b --- /dev/null +++ b/server/src/lib/billing.ts @@ -0,0 +1,131 @@ +import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm' +import { db } from '../db/client.js' +import { billingSubscriptions, episodes, generationJobs, renders, shots, usageLedger, workspaces } from '../db/schema.js' +import type { JobKind } from '../queue/types.js' +import { BILLING_PLAN_CATALOG } from './billing-plans.js' +import type { BillingProviderReadiness } from './stripe-billing.js' + +export function billingPeriod(now = new Date()) { + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)) + return { start, end } +} + +function numberValue(value: string | number | null | undefined) { + const parsed = Number(value ?? 0) + return Number.isFinite(parsed) ? parsed : 0 +} + +export async function estimateJobCredits(type: JobKind, input: Record) { + if (type === 'PIPELINE_HEALTHCHECK' || type === 'DELIVERY_PUBLISH') return 0 + if (type === 'SCRIPT_GENERATE') return 30 + if (type === 'STORYBOARD_GENERATE') return 40 + if (type === 'ASSET_GENERATE') return 120 + if (type === 'TTS_GENERATE') { + const text = typeof input.text === 'string' ? input.text : '' + return Math.max(1, Math.ceil(text.length / 5) * 0.6) + } + if (type === 'VIDEO_GENERATE') { + const duration = numberValue(typeof input.durationSeconds === 'number' || typeof input.durationSeconds === 'string' ? input.durationSeconds : 0) || 5 + return Math.max(1, duration) * 35 + } + if (type === 'LIPSYNC_GENERATE') return 60 + if (type === 'EPISODE_RENDER' && typeof input.renderId === 'string') { + const [row] = await db.select({ episodeId: renders.episodeId, targetDurationSeconds: episodes.targetDurationSeconds }).from(renders) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .where(eq(renders.id, input.renderId)).limit(1) + if (row) { + const durations = await db.select({ durationMs: shots.durationMs }).from(shots).where(eq(shots.episodeId, row.episodeId)) + const seconds = durations.length ? durations.reduce((total, shot) => total + shot.durationMs, 0) / 1000 : row.targetDurationSeconds + return Math.max(1, seconds) * 0.8 + } + } + return 100 +} + +export async function workspaceBillingSummary(workspaceId: string, options: { + now?: Date + providerReadiness: BillingProviderReadiness + canManage: boolean +}) { + const now = options.now ?? new Date() + const { start, end } = billingPeriod(now) + const [[workspace], [subscription]] = await Promise.all([ + db.select({ + id: workspaces.id, + plan: workspaces.plan, + monthlyCreditLimit: workspaces.monthlyCreditLimit, + }).from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1), + db.select().from(billingSubscriptions).where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1), + ]) + if (!workspace) return null + + const [[usage], [reservation], breakdown, recent] = await Promise.all([ + db.select({ credits: sql`coalesce(sum(${usageLedger.credits}), 0)` }).from(usageLedger).where(and( + eq(usageLedger.workspaceId, workspaceId), + gte(usageLedger.occurredAt, start), + lt(usageLedger.occurredAt, end), + )), + db.select({ credits: sql`coalesce(sum(${generationJobs.estimatedCredits}), 0)` }).from(generationJobs).where(and( + eq(generationJobs.workspaceId, workspaceId), + inArray(generationJobs.status, ['QUEUED', 'RUNNING']), + )), + db.select({ + kind: usageLedger.kind, + quantity: sql`coalesce(sum(${usageLedger.quantity}), 0)`, + credits: sql`coalesce(sum(${usageLedger.credits}), 0)`, + }).from(usageLedger).where(and( + eq(usageLedger.workspaceId, workspaceId), + gte(usageLedger.occurredAt, start), + lt(usageLedger.occurredAt, end), + )).groupBy(usageLedger.kind).orderBy(desc(sql`sum(${usageLedger.credits})`)), + db.select().from(usageLedger).where(and( + eq(usageLedger.workspaceId, workspaceId), + gte(usageLedger.occurredAt, start), + lt(usageLedger.occurredAt, end), + )).orderBy(desc(usageLedger.occurredAt)).limit(50), + ]) + + const usedCredits = numberValue(usage?.credits) + const reservedCredits = numberValue(reservation?.credits) + const remainingCredits = Math.max(0, workspace.monthlyCreditLimit - usedCredits - reservedCredits) + return { + plan: workspace.plan, + monthlyCreditLimit: workspace.monthlyCreditLimit, + usedCredits, + reservedCredits, + remainingCredits, + utilizationPercent: workspace.monthlyCreditLimit > 0 ? Math.min(100, (usedCredits + reservedCredits) / workspace.monthlyCreditLimit * 100) : 100, + canManage: options.canManage, + provider: { + name: options.providerReadiness.provider, + status: options.providerReadiness.status, + configured: Object.values(options.providerReadiness.plans).some((plan) => plan.checkoutAvailable), + missingRequirements: options.providerReadiness.missingRequirements, + portalAvailable: options.providerReadiness.portalConfigured && Boolean(subscription), + webhookConfigured: options.providerReadiness.webhookConfigured, + }, + plans: BILLING_PLAN_CATALOG.map((plan) => ({ + id: plan.id, + monthlyCreditLimit: plan.monthlyCreditLimit, + purchasable: plan.purchasable, + checkoutAvailable: plan.purchasable + ? options.providerReadiness.plans[plan.id].checkoutAvailable + : false, + missingRequirements: plan.purchasable + ? options.providerReadiness.plans[plan.id].missingRequirements + : [], + })), + subscription: subscription ? { + provider: subscription.provider, + status: subscription.status, + plan: subscription.plan, + hasSubscription: Boolean(subscription.subscriptionId), + currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, + } : null, + period: { start: start.toISOString(), end: end.toISOString() }, + breakdown: breakdown.map((row) => ({ kind: row.kind, quantity: numberValue(row.quantity), credits: numberValue(row.credits) })), + recent: recent.map((row) => ({ ...row, quantity: numberValue(row.quantity), credits: numberValue(row.credits), providerCostUsd: row.providerCostUsd === null ? null : numberValue(row.providerCostUsd) })), + } +} diff --git a/server/src/lib/character-consistency.test.ts b/server/src/lib/character-consistency.test.ts new file mode 100644 index 0000000..417cb91 --- /dev/null +++ b/server/src/lib/character-consistency.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { applyCharacterConsistency, type AppliedCharacterProfile } from './character-consistency.js' + +function profile(overrides: Partial = {}): AppliedCharacterProfile { + return { + schemaVersion: 1, + assetId: crypto.randomUUID(), + assetName: '林遥', + referenceAssetVersionIds: [crypto.randomUUID()], + identityPrompt: '短发,琥珀色眼睛,面部轮廓保持一致', + costumePrompt: '深色风衣与银色胸针', + stylePrompt: '写实漫剧,冷色雨夜光线', + negativePrompt: '脸型漂移,服装变色', + consistencyStrength: 88, + identityLocked: true, + updatedAt: '2026-07-30T12:00:00.000Z', + updatedById: crypto.randomUUID(), + ...overrides, + } +} + +describe('character consistency application', () => { + it('prioritizes locked identity references and records an applied snapshot', () => { + const locked = profile() + const automaticReference = crypto.randomUUID() + const manualReference = crypto.randomUUID() + const result = applyCharacterConsistency({ + prompt: '人物推开天台铁门', + negativePrompt: '多余手指', + params: { consistencyStrength: 55, seedMode: 'random' }, + manualReferenceAssetVersionIds: [manualReference, locked.referenceAssetVersionIds[0]], + otherAutomaticReferenceAssetVersionIds: [automaticReference], + profiles: [locked], + }) + + expect(result.referenceAssetVersionIds).toEqual([ + locked.referenceAssetVersionIds[0], + automaticReference, + manualReference, + ]) + expect(result.params).toMatchObject({ consistencyStrength: 88, seedMode: 'random' }) + expect(result.prompt).toContain('角色「林遥」') + expect(result.prompt).toContain(locked.identityPrompt) + expect(result.prompt).toContain(locked.costumePrompt) + expect(result.negativePrompt).toBe('多余手指;脸型漂移,服装变色') + expect(result.profileSnapshots).toEqual([expect.objectContaining({ + assetId: locked.assetId, + referenceAssetVersionIds: locked.referenceAssetVersionIds, + identityLocked: true, + })]) + }) + + it('does not enforce an unlocked draft profile', () => { + const draft = profile({ identityLocked: false }) + const result = applyCharacterConsistency({ + prompt: '原始提示词', + negativePrompt: '', + params: { consistencyStrength: 64 }, + manualReferenceAssetVersionIds: [], + profiles: [draft], + }) + + expect(result.prompt).toBe('原始提示词') + expect(result.params.consistencyStrength).toBe(64) + expect(result.referenceAssetVersionIds).toEqual([]) + expect(result.profileSnapshots).toEqual([]) + }) +}) diff --git a/server/src/lib/character-consistency.ts b/server/src/lib/character-consistency.ts new file mode 100644 index 0000000..cb9baa4 --- /dev/null +++ b/server/src/lib/character-consistency.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' + +export const characterConsistencyProfileInputSchema = z.object({ + referenceAssetVersionIds: z.array(z.uuid()).max(16).default([]), + identityPrompt: z.string().trim().max(4000).default(''), + costumePrompt: z.string().trim().max(4000).default(''), + stylePrompt: z.string().trim().max(4000).default(''), + negativePrompt: z.string().trim().max(4000).default(''), + consistencyStrength: z.number().int().min(50).max(100).default(82), + identityLocked: z.boolean().default(false), +}).superRefine((profile, context) => { + if (new Set(profile.referenceAssetVersionIds).size !== profile.referenceAssetVersionIds.length) { + context.addIssue({ code: 'custom', path: ['referenceAssetVersionIds'], message: 'Reference versions must be unique' }) + } + if (profile.identityLocked && profile.referenceAssetVersionIds.length === 0) { + context.addIssue({ code: 'custom', path: ['referenceAssetVersionIds'], message: 'A locked identity requires at least one reference version' }) + } +}) + +export type CharacterConsistencyProfileInput = z.infer + +export const characterConsistencyProfileSchema = characterConsistencyProfileInputSchema.and(z.object({ + schemaVersion: z.literal(1), + updatedAt: z.iso.datetime({ offset: true }), + updatedById: z.uuid(), +})) + +export type CharacterConsistencyProfile = z.infer + +export interface AppliedCharacterProfile extends CharacterConsistencyProfile { + assetId: string + assetName: string +} + +export function readCharacterConsistencyProfile(metadata: Record | null | undefined) { + const parsed = characterConsistencyProfileSchema.safeParse(metadata?.consistencyProfile) + return parsed.success ? parsed.data : null +} + +export function createCharacterConsistencyProfile( + input: CharacterConsistencyProfileInput, + updatedById: string, + updatedAt = new Date(), +): CharacterConsistencyProfile { + return { + ...input, + schemaVersion: 1, + updatedAt: updatedAt.toISOString(), + updatedById, + } +} + +function appendPrompt(base: string, profiles: AppliedCharacterProfile[]) { + const blocks = profiles.map((profile) => { + const fields = [ + profile.identityPrompt ? `身份特征:${profile.identityPrompt}` : '', + profile.costumePrompt ? `服装约束:${profile.costumePrompt}` : '', + profile.stylePrompt ? `视觉风格:${profile.stylePrompt}` : '', + ].filter(Boolean) + return fields.length > 0 ? `角色「${profile.assetName}」\n${fields.join('\n')}` : '' + }).filter(Boolean) + return blocks.length > 0 ? [base, '角色一致性约束:', ...blocks].join('\n') : base +} + +function mergeNegativePrompt(base: string, profiles: AppliedCharacterProfile[]) { + return [...new Set([base.trim(), ...profiles.map((profile) => profile.negativePrompt.trim())].filter(Boolean))].join(';') +} + +export function applyCharacterConsistency(input: { + prompt: string + negativePrompt: string + params: Record + manualReferenceAssetVersionIds: string[] + profiles: AppliedCharacterProfile[] + otherAutomaticReferenceAssetVersionIds?: string[] +}) { + const lockedProfiles = input.profiles.filter((profile) => profile.identityLocked) + const lockedReferenceIds = [...new Set(lockedProfiles.flatMap((profile) => profile.referenceAssetVersionIds))] + if (lockedReferenceIds.length > 16) { + throw new Error('Locked character profiles contain more than 16 unique identity references') + } + const referenceAssetVersionIds = [...new Set([ + ...lockedReferenceIds, + ...(input.otherAutomaticReferenceAssetVersionIds ?? []), + ...input.manualReferenceAssetVersionIds, + ])].slice(0, 16) + const consistencyStrength = lockedProfiles.length > 0 + ? Math.max(...lockedProfiles.map((profile) => profile.consistencyStrength)) + : undefined + const params = { + ...input.params, + ...(consistencyStrength === undefined ? {} : { consistencyStrength }), + } + const profileSnapshots = lockedProfiles.map((profile) => ({ + schemaVersion: profile.schemaVersion, + assetId: profile.assetId, + assetName: profile.assetName, + referenceAssetVersionIds: [...profile.referenceAssetVersionIds], + identityPrompt: profile.identityPrompt, + costumePrompt: profile.costumePrompt, + stylePrompt: profile.stylePrompt, + negativePrompt: profile.negativePrompt, + consistencyStrength: profile.consistencyStrength, + identityLocked: true as const, + updatedAt: profile.updatedAt, + updatedById: profile.updatedById, + })) + + return { + prompt: appendPrompt(input.prompt, lockedProfiles), + negativePrompt: mergeNegativePrompt(input.negativePrompt, lockedProfiles), + params, + referenceAssetVersionIds, + profileSnapshots, + } +} diff --git a/server/src/lib/credential-vault.test.ts b/server/src/lib/credential-vault.test.ts new file mode 100644 index 0000000..b3ff151 --- /dev/null +++ b/server/src/lib/credential-vault.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { decryptCredential, encryptCredential } from './credential-vault.js' + +const secret = 'vault-test-secret-with-at-least-32-characters' + +describe('credential vault', () => { + it('encrypts credentials with randomized authenticated ciphertext', () => { + const first = encryptCredential('provider-access-token', secret) + const second = encryptCredential('provider-access-token', secret) + expect(first).not.toBe(second) + expect(first).not.toContain('provider-access-token') + expect(decryptCredential(first, secret)).toBe('provider-access-token') + expect(decryptCredential(second, secret)).toBe('provider-access-token') + }) + + it('rejects tampering, wrong keys, and unsupported formats', () => { + const encrypted = encryptCredential('refresh-token', secret) + const parts = encrypted.split('.') + const ciphertext = Buffer.from(parts[3]!, 'base64url') + ciphertext[0] ^= 1 + parts[3] = ciphertext.toString('base64url') + expect(() => decryptCredential(parts.join('.'), secret)).toThrow('could not be decrypted') + expect(() => decryptCredential(encrypted, 'different-secret-with-at-least-32-characters')).toThrow('could not be decrypted') + expect(() => decryptCredential('plaintext-token', secret)).toThrow('unsupported format') + }) + + it('rejects non-canonical base64url components even when they decode to the same bytes', () => { + const encrypted = encryptCredential('refresh-token', secret) + const parts = encrypted.split('.') + const encoded = parts[3]! + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + const lastIndex = alphabet.indexOf(encoded.at(-1)!) + const alternateIndex = (lastIndex & 0b110000) | ((lastIndex + 1) & 0b001111) + const nonCanonical = `${encoded.slice(0, -1)}${alphabet[alternateIndex]}` + expect(Buffer.from(nonCanonical, 'base64url')).toEqual(Buffer.from(encoded, 'base64url')) + parts[3] = nonCanonical + expect(() => decryptCredential(parts.join('.'), secret)).toThrow('could not be decrypted') + }) +}) diff --git a/server/src/lib/credential-vault.ts b/server/src/lib/credential-vault.ts new file mode 100644 index 0000000..61d821b --- /dev/null +++ b/server/src/lib/credential-vault.ts @@ -0,0 +1,51 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { config } from '../config.js' + +const VERSION = 'v1' +const ALGORITHM = 'aes-256-gcm' + +function encryptionKey(secret: string) { + return createHash('sha256').update(secret, 'utf8').digest() +} + +function decodeBase64Url(value: string, expectedLength?: number) { + if (!/^[a-zA-Z0-9_-]+$/.test(value)) throw new Error('invalid base64url') + const decoded = Buffer.from(value, 'base64url') + if ( + decoded.length === 0 + || decoded.toString('base64url') !== value + || (expectedLength !== undefined && decoded.length !== expectedLength) + ) { + throw new Error('invalid base64url') + } + return decoded +} + +export function encryptCredential(value: string, secret = config.CREDENTIAL_ENCRYPTION_KEY) { + if (!value) throw new Error('Credential value cannot be empty') + const iv = randomBytes(12) + const cipher = createCipheriv(ALGORITHM, encryptionKey(secret), iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return [VERSION, iv.toString('base64url'), tag.toString('base64url'), encrypted.toString('base64url')].join('.') +} + +export function decryptCredential(value: string, secret = config.CREDENTIAL_ENCRYPTION_KEY) { + const [version, ivValue, tagValue, encryptedValue, extra] = value.split('.') + if (version !== VERSION || !ivValue || !tagValue || !encryptedValue || extra) { + throw new Error('Stored credential has an unsupported format') + } + try { + const iv = decodeBase64Url(ivValue, 12) + const tag = decodeBase64Url(tagValue, 16) + const encrypted = decodeBase64Url(encryptedValue) + const decipher = createDecipheriv(ALGORITHM, encryptionKey(secret), iv) + decipher.setAuthTag(tag) + return Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]).toString('utf8') + } catch { + throw new Error('Stored credential could not be decrypted') + } +} diff --git a/server/src/lib/errors.ts b/server/src/lib/errors.ts new file mode 100644 index 0000000..41ba3bf --- /dev/null +++ b/server/src/lib/errors.ts @@ -0,0 +1,24 @@ +export class AppError extends Error { + readonly statusCode: number + readonly code: string + readonly details?: unknown + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message) + this.name = 'AppError' + this.statusCode = statusCode + this.code = code + this.details = details + } +} + +export const errors = { + badRequest: (message: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', message, details), + unauthorized: (message = 'Authentication required') => new AppError(401, 'UNAUTHORIZED', message), + emailVerificationRequired: () => new AppError(403, 'EMAIL_VERIFICATION_REQUIRED', '请先完成邮箱验证'), + forbidden: (message = 'Insufficient permissions') => new AppError(403, 'FORBIDDEN', message), + notFound: (resource = 'Resource') => new AppError(404, 'NOT_FOUND', `${resource} not found`), + conflict: (message: string) => new AppError(409, 'CONFLICT', message), + serviceUnavailable: (message: string, details?: unknown) => new AppError(503, 'SERVICE_UNAVAILABLE', message, details), + creditLimitExceeded: (details?: unknown) => new AppError(402, 'CREDIT_LIMIT_EXCEEDED', 'Workspace credit limit would be exceeded', details), +} diff --git a/server/src/lib/http.ts b/server/src/lib/http.ts new file mode 100644 index 0000000..1292451 --- /dev/null +++ b/server/src/lib/http.ts @@ -0,0 +1,20 @@ +import type { FastifyRequest } from 'fastify' +import { type ZodType, ZodError } from 'zod' +import { errors } from './errors.js' + +export function parseWith(schema: ZodType, value: unknown): T { + try { + return schema.parse(value) + } catch (error) { + if (error instanceof ZodError) { + throw errors.badRequest('Request validation failed', error.issues) + } + throw error + } +} +export function requestMeta(request: FastifyRequest) { + return { + ipAddress: request.ip, + userAgent: request.headers['user-agent'] ?? null, + } +} diff --git a/server/src/lib/mailer.test.ts b/server/src/lib/mailer.test.ts new file mode 100644 index 0000000..49c2371 --- /dev/null +++ b/server/src/lib/mailer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { sendWorkspaceInvitationEmail, workspaceInvitationUrl } from './mailer.js' + +describe('workspace invitation email', () => { + it('builds an invitation URL on the configured public origin', () => { + const url = new URL(workspaceInvitationUrl('invitation-token')) + expect(url.searchParams.get('invite')).toBe('invitation-token') + expect([...url.searchParams.keys()]).toEqual(['invite']) + expect(url.hash).toBe('') + }) + + it('sends escaped HTML and a complete plain-text fallback through Resend', async () => { + let requestBody: Record = {} + const fetchMock = vi.fn(async (_input, init) => { + requestBody = JSON.parse(String(init?.body)) as Record + return new Response(JSON.stringify({ id: 'email_123' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + const delivery = await sendWorkspaceInvitationEmail({ + to: 'invitee@example.com', + inviterName: '<导演>', + workspaceName: '北岸 & 工作室', + roleLabel: '编辑', + expiresAt: new Date('2026-08-08T12:00:00.000Z'), + url: 'https://studio.example.com/?invite=token&source=email', + }, { + apiKey: 're_test_key', + from: 'FrameFlow ', + fetchImpl: fetchMock, + }) + + expect(delivery).toEqual({ sent: true, provider: 'resend', id: 'email_123' }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(requestBody).toMatchObject({ + from: 'FrameFlow ', + to: ['invitee@example.com'], + subject: '<导演> 邀请你加入 北岸 & 工作室', + }) + expect(String(requestBody.text)).toContain('https://studio.example.com/?invite=token&source=email') + expect(String(requestBody.html)).toContain('<导演>') + expect(String(requestBody.html)).toContain('北岸 & 工作室') + expect(String(requestBody.html)).toContain('invite=token&source=email') + expect(String(requestBody.html)).not.toContain('<导演>') + }) + + it('reports a development fallback without making a network call', async () => { + const fetchMock = vi.fn() + const delivery = await sendWorkspaceInvitationEmail({ + to: 'invitee@example.com', + inviterName: '导演', + workspaceName: '北岸工作室', + roleLabel: '编辑', + expiresAt: new Date('2026-08-08T12:00:00.000Z'), + url: 'http://127.0.0.1:4173/?invite=token', + }, { apiKey: '', fetchImpl: fetchMock }) + + expect(delivery).toEqual({ sent: false, provider: 'development' }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/server/src/lib/mailer.ts b/server/src/lib/mailer.ts new file mode 100644 index 0000000..20eb22c --- /dev/null +++ b/server/src/lib/mailer.ts @@ -0,0 +1,128 @@ +import { config } from '../config.js' +import type { AccountTokenType } from './account-tokens.js' + +export interface EmailTransportOptions { + apiKey?: string + from?: string + fetchImpl?: typeof fetch +} + +export interface WorkspaceInvitationEmailInput { + to: string + inviterName: string + workspaceName: string + roleLabel: string + expiresAt: Date + url: string +} + +export interface InvitationMailer { + send(input: WorkspaceInvitationEmailInput): Promise +} + +export type EmailDeliveryResult = + | { sent: false; provider: 'development' } + | { sent: true; provider: 'resend'; id: string | null } + +function escapeHtml(value: string) { + return value.replace(/[&<>"']/g, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character]!) +} + +export function accountActionUrl(type: AccountTokenType, token: string) { + const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173' + const url = new URL(origin) + url.search = '' + url.hash = '' + url.searchParams.set(type === 'EMAIL_VERIFICATION' ? 'verify_email' : 'reset_password', token) + return url.toString() +} + +export function workspaceInvitationUrl(token: string) { + const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173' + const url = new URL(origin) + url.search = '' + url.hash = '' + url.searchParams.set('invite', token) + return url.toString() +} + +async function sendEmail(input: { + to: string + subject: string + text: string + html: string +}, options: EmailTransportOptions = {}): Promise { + const apiKey = options.apiKey ?? config.RESEND_API_KEY + if (!apiKey) return { sent: false, provider: 'development' } + + const response = await (options.fetchImpl ?? fetch)('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from: options.from ?? config.EMAIL_FROM, + to: [input.to], + subject: input.subject, + text: input.text, + html: input.html, + }), + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) throw new Error(`Email provider returned ${response.status}: ${(await response.text()).slice(0, 500)}`) + const body = await response.json() as { id?: string } + return { sent: true, provider: 'resend', id: body.id ?? null } +} + +export async function sendAccountActionEmail(input: { + type: AccountTokenType + to: string + displayName: string + url: string +}, options: EmailTransportOptions = {}) { + const verification = input.type === 'EMAIL_VERIFICATION' + const title = verification ? '验证你的 FrameFlow 邮箱' : '重置你的 FrameFlow 密码' + const action = verification ? '验证邮箱' : '重置密码' + const lifetime = verification + ? `${config.EMAIL_VERIFICATION_TTL_HOURS} 小时` + : `${config.PASSWORD_RESET_TTL_MINUTES} 分钟` + const safeName = escapeHtml(input.displayName) + const safeUrl = escapeHtml(input.url) + return sendEmail({ + to: input.to, + subject: title, + text: `${input.displayName},请在 ${lifetime}内打开以下链接完成${action}:\n\n${input.url}\n\n如果不是你发起的操作,请忽略此邮件。`, + html: `

${safeName},你好。

请在 ${lifetime}内完成${action}。

${action}

如果不是你发起的操作,请忽略此邮件。

`, + }, options) +} + +export async function sendWorkspaceInvitationEmail( + input: WorkspaceInvitationEmailInput, + options: EmailTransportOptions = {}, +) { + const expiresAt = new Intl.DateTimeFormat('zh-CN', { + dateStyle: 'long', + timeStyle: 'short', + }).format(input.expiresAt) + const safeInviterName = escapeHtml(input.inviterName) + const safeWorkspaceName = escapeHtml(input.workspaceName) + const safeRoleLabel = escapeHtml(input.roleLabel) + const safeUrl = escapeHtml(input.url) + return sendEmail({ + to: input.to, + subject: `${input.inviterName} 邀请你加入 ${input.workspaceName}`, + text: `${input.inviterName} 邀请你加入 FrameFlow 工作室「${input.workspaceName}」,初始角色为${input.roleLabel}。\n\n请在 ${expiresAt} 前打开以下链接接受邀请:\n\n${input.url}\n\n该链接只能由收到邀请的邮箱账号使用。如果你不认识邀请人,请忽略此邮件。`, + html: `

${safeInviterName} 邀请你加入 FrameFlow 工作室「${safeWorkspaceName}」。

初始角色:${safeRoleLabel}

请在 ${escapeHtml(expiresAt)} 前接受邀请。

加入工作室

该链接只能由收到邀请的邮箱账号使用。如果你不认识邀请人,请忽略此邮件。

`, + }, options) +} + +export const resendInvitationMailer: InvitationMailer = { + send: sendWorkspaceInvitationEmail, +} diff --git a/server/src/lib/metrics.ts b/server/src/lib/metrics.ts new file mode 100644 index 0000000..95eed5d --- /dev/null +++ b/server/src/lib/metrics.ts @@ -0,0 +1,62 @@ +import { collectDefaultMetrics, Counter, Gauge, Histogram, Registry } from 'prom-client' + +export const metricsRegistry = new Registry() + +collectDefaultMetrics({ register: metricsRegistry, prefix: 'frameflow_' }) + +export const apiRequestsTotal = new Counter({ + name: 'frameflow_api_requests_total', + help: 'Total API requests', + labelNames: ['method', 'route', 'status'] as const, + registers: [metricsRegistry], +}) + +export const apiRequestDurationSeconds = new Histogram({ + name: 'frameflow_api_request_duration_seconds', + help: 'API request duration in seconds', + labelNames: ['method', 'route', 'status'] as const, + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], + registers: [metricsRegistry], +}) + +export const applicationDependencyAvailable = new Gauge({ + name: 'frameflow_application_dependency_available', + help: 'Whether an API readiness dependency is currently available (1 or 0)', + labelNames: ['dependency'] as const, + registers: [metricsRegistry], +}) + +export const generationJobsProcessedTotal = new Counter({ + name: 'frameflow_generation_jobs_processed_total', + help: 'Generation job attempts by result', + labelNames: ['type', 'result'] as const, + registers: [metricsRegistry], +}) + +export const generationJobDurationSeconds = new Histogram({ + name: 'frameflow_generation_job_duration_seconds', + help: 'Generation job attempt duration in seconds', + labelNames: ['type'] as const, + buckets: [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600], + registers: [metricsRegistry], +}) + +export const generationWorkerActiveJobs = new Gauge({ + name: 'frameflow_generation_worker_active_jobs', + help: 'Generation jobs currently handled by this worker', + registers: [metricsRegistry], +}) + +export const generationWorkerHeartbeatTimestampSeconds = new Gauge({ + name: 'frameflow_generation_worker_heartbeat_timestamp_seconds', + help: 'Unix timestamp of the last successful worker heartbeat write', + registers: [metricsRegistry], +}) + +export const generationJobQueueDelaySeconds = new Histogram({ + name: 'frameflow_generation_job_queue_delay_seconds', + help: 'Time from queue submission until a worker starts the job', + labelNames: ['type'] as const, + buckets: [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600, 1800], + registers: [metricsRegistry], +}) diff --git a/server/src/lib/notifications.test.ts b/server/src/lib/notifications.test.ts new file mode 100644 index 0000000..c634f33 --- /dev/null +++ b/server/src/lib/notifications.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { buildJobTerminalMessage } from './notifications.js' + +describe('job terminal notification copy', () => { + it('describes an asynchronous platform delivery as submitted', () => { + expect(buildJobTerminalMessage({ + jobType: 'DELIVERY_PUBLISH', + status: 'SUCCEEDED', + output: { status: 'PUBLISHING' }, + })).toEqual({ + title: '平台发布已提交', + body: '平台已接收发布任务,正在处理中。最终结果会在发布管理中更新。', + }) + }) + + it('describes a terminal delivery as completed', () => { + expect(buildJobTerminalMessage({ + jobType: 'DELIVERY_PUBLISH', + status: 'SUCCEEDED', + output: { status: 'PUBLISHED' }, + })).toEqual({ + title: '成片交付已完成', + body: '成片已完成交付,可前往发布管理查看详情。', + }) + }) + + it('keeps ordinary job and failure messages unchanged', () => { + expect(buildJobTerminalMessage({ jobType: 'SCRIPT_GENERATE', status: 'SUCCEEDED' }).title).toBe('剧本生成已完成') + expect(buildJobTerminalMessage({ + jobType: 'VIDEO_GENERATE', + status: 'FAILED', + errorMessage: '服务商超时', + })).toEqual({ title: '视频生成失败', body: '服务商超时' }) + }) +}) diff --git a/server/src/lib/notifications.ts b/server/src/lib/notifications.ts new file mode 100644 index 0000000..8abebd6 --- /dev/null +++ b/server/src/lib/notifications.ts @@ -0,0 +1,240 @@ +import { and, eq, inArray } from 'drizzle-orm' +import { db } from '../db/client.js' +import { memberships, notifications } from '../db/schema.js' +import type { Notification } from '../db/schema.js' +import { publishRealtimeEvent } from './realtime.js' + +type NotificationTone = Notification['tone'] + +export interface CreateNotificationInput { + workspaceId: string + userId: string + kind: string + tone: NotificationTone + title: string + body?: string + page: string + resourceType?: string + resourceId?: string + dedupeKey: string + metadata?: Record +} + +export async function createNotifications(inputs: CreateNotificationInput[]) { + if (inputs.length === 0) return [] + const created = await db.insert(notifications).values(inputs.map((input) => ({ + workspaceId: input.workspaceId, + userId: input.userId, + kind: input.kind, + tone: input.tone, + title: input.title.slice(0, 240), + body: (input.body ?? '').slice(0, 10_000), + page: input.page, + resourceType: input.resourceType, + resourceId: input.resourceId, + dedupeKey: input.dedupeKey, + metadata: input.metadata ?? {}, + }))).onConflictDoNothing().returning() + + await Promise.all(created.map((notification) => publishRealtimeEvent({ + workspaceId: notification.workspaceId, + userId: notification.userId, + type: 'notification.created', + resourceType: 'notification', + resourceId: notification.id, + payload: { notification }, + }))) + return created +} + +export async function createNotification(input: CreateNotificationInput) { + const [created] = await createNotifications([input]) + return created ?? null +} + +const jobLabels: Record = { + SCRIPT_GENERATE: '剧本生成', + STORYBOARD_GENERATE: '智能分镜', + ASSET_GENERATE: '画面生成', + TTS_GENERATE: '配音生成', + VIDEO_GENERATE: '视频生成', + LIPSYNC_GENERATE: '口型同步', + EPISODE_RENDER: '成片渲染', + DELIVERY_PUBLISH: '成片交付', +} + +interface JobTerminalInput { + workspaceId: string + userId: string + jobId: string + jobType: string + status: 'SUCCEEDED' | 'FAILED' + errorMessage?: string + projectId?: string + episodeId?: string + output?: Record +} + +export function buildJobTerminalMessage(input: Pick) { + const succeeded = input.status === 'SUCCEEDED' + const label = jobLabels[input.jobType] ?? input.jobType + if (!succeeded) { + return { + title: `${label}失败`, + body: input.errorMessage || '任务执行失败,可前往任务中心重试。', + } + } + if (input.jobType === 'DELIVERY_PUBLISH' && input.output?.status === 'PUBLISHING') { + return { + title: '平台发布已提交', + body: '平台已接收发布任务,正在处理中。最终结果会在发布管理中更新。', + } + } + if (input.jobType === 'DELIVERY_PUBLISH' && input.output?.status === 'PUBLISHED') { + return { + title: '成片交付已完成', + body: '成片已完成交付,可前往发布管理查看详情。', + } + } + return { + title: `${label}已完成`, + body: '产出已保存,可以继续下一步制作。', + } +} + +export async function notifyJobTerminal(input: JobTerminalInput) { + if (input.jobType === 'PIPELINE_HEALTHCHECK') return null + const succeeded = input.status === 'SUCCEEDED' + const page = input.jobType === 'DELIVERY_PUBLISH' ? 'publish' : 'jobs' + const message = buildJobTerminalMessage(input) + return createNotification({ + workspaceId: input.workspaceId, + userId: input.userId, + kind: succeeded ? 'JOB_SUCCEEDED' : 'JOB_FAILED', + tone: succeeded ? 'SUCCESS' : 'ERROR', + title: message.title, + body: message.body, + page, + resourceType: 'generation_job', + resourceId: input.jobId, + dedupeKey: `job:${input.jobId}:${input.status}`, + metadata: { + jobType: input.jobType, + status: input.status, + deliveryStatus: typeof input.output?.status === 'string' ? input.output.status : undefined, + projectId: input.projectId, + episodeId: input.episodeId, + }, + }) +} + +export async function reviewRecipientIds(workspaceId: string, assignedToId?: string | null) { + if (assignedToId) return [assignedToId] + const rows = await db.select({ userId: memberships.userId }).from(memberships) + .where(and( + eq(memberships.workspaceId, workspaceId), + inArray(memberships.role, ['OWNER', 'ADMIN', 'REVIEWER']), + )) + return rows.map((row) => row.userId) +} + +export async function notifyReviewRequested(input: { + workspaceId: string + reviewId: string + requestedById: string + assignedToId?: string | null + summary?: string + targetType: string +}) { + const recipients = (await reviewRecipientIds(input.workspaceId, input.assignedToId)) + .filter((userId) => userId !== input.requestedById) + return createNotifications(recipients.map((userId) => ({ + workspaceId: input.workspaceId, + userId, + kind: 'REVIEW_REQUESTED', + tone: 'WARNING', + title: '收到新的审核任务', + body: input.summary || '有一个制作版本等待审核。', + page: 'reviews', + resourceType: 'review_request', + resourceId: input.reviewId, + dedupeKey: `review:${input.reviewId}:requested`, + metadata: { targetType: input.targetType }, + }))) +} + +export async function notifyReviewAssigned(input: { + workspaceId: string + reviewId: string + assignedToId: string + assignedById: string + summary?: string +}) { + if (input.assignedToId === input.assignedById) return null + return createNotification({ + workspaceId: input.workspaceId, + userId: input.assignedToId, + kind: 'REVIEW_ASSIGNED', + tone: 'WARNING', + title: '审核任务已指派给你', + body: input.summary || '请前往审核中心处理。', + page: 'reviews', + resourceType: 'review_request', + resourceId: input.reviewId, + dedupeKey: `review:${input.reviewId}:assigned:${input.assignedToId}`, + }) +} + +export async function notifyReviewComment(input: { + workspaceId: string + reviewId: string + authorId: string + requestedById: string + assignedToId?: string | null + body: string + commentId: string +}) { + const recipients = [...new Set([input.requestedById, input.assignedToId].filter((value): value is string => Boolean(value)))] + .filter((userId) => userId !== input.authorId) + return createNotifications(recipients.map((userId) => ({ + workspaceId: input.workspaceId, + userId, + kind: 'REVIEW_COMMENTED', + tone: 'INFO', + title: '审核收到新意见', + body: input.body, + page: 'reviews', + resourceType: 'review_request', + resourceId: input.reviewId, + dedupeKey: `review-comment:${input.commentId}:${userId}`, + }))) +} + +export async function notifyReviewResolved(input: { + workspaceId: string + reviewId: string + requestedById: string + resolvedById: string + status: 'APPROVED' | 'CHANGES_REQUESTED' | 'CANCELLED' + summary?: string +}) { + if (input.requestedById === input.resolvedById) return null + const statusCopy = { + APPROVED: { title: '审核已通过', tone: 'SUCCESS' as const }, + CHANGES_REQUESTED: { title: '审核已退回修改', tone: 'WARNING' as const }, + CANCELLED: { title: '审核已取消', tone: 'INFO' as const }, + }[input.status] + return createNotification({ + workspaceId: input.workspaceId, + userId: input.requestedById, + kind: 'REVIEW_RESOLVED', + tone: statusCopy.tone, + title: statusCopy.title, + body: input.summary || '审核状态已经更新。', + page: 'reviews', + resourceType: 'review_request', + resourceId: input.reviewId, + dedupeKey: `review:${input.reviewId}:resolved:${input.status}`, + metadata: { status: input.status }, + }) +} diff --git a/server/src/lib/permissions.ts b/server/src/lib/permissions.ts new file mode 100644 index 0000000..e985942 --- /dev/null +++ b/server/src/lib/permissions.ts @@ -0,0 +1,51 @@ +import type { FastifyRequest } from 'fastify' +import { and, eq } from 'drizzle-orm' +import { db } from '../db/client.js' +import { memberships, projects } from '../db/schema.js' +import { errors } from './errors.js' + +export type WorkspaceRole = 'OWNER' | 'ADMIN' | 'EDITOR' | 'REVIEWER' | 'VIEWER' +export type Permission = + | 'workspace:manage' + | 'members:manage' + | 'project:read' + | 'project:write' + | 'content:write' + | 'review:decide' + | 'render:create' + | 'publish:create' + | 'publishing:manage' + | 'billing:read' + | 'billing:manage' + | 'services:verify' + | 'audit:read' + +const permissions: Record> = { + OWNER: new Set(['workspace:manage', 'members:manage', 'project:read', 'project:write', 'content:write', 'review:decide', 'render:create', 'publish:create', 'publishing:manage', 'billing:read', 'billing:manage', 'services:verify', 'audit:read']), + ADMIN: new Set(['workspace:manage', 'members:manage', 'project:read', 'project:write', 'content:write', 'review:decide', 'render:create', 'publish:create', 'publishing:manage', 'billing:read', 'billing:manage', 'services:verify', 'audit:read']), + EDITOR: new Set(['project:read', 'project:write', 'content:write', 'render:create']), + REVIEWER: new Set(['project:read', 'review:decide']), + VIEWER: new Set(['project:read']), +} + +export function can(role: WorkspaceRole, permission: Permission) { + return permissions[role].has(permission) +} + +export async function requireWorkspacePermission(request: FastifyRequest, workspaceId: string, permission: Permission) { + const [membership] = await db.select({ role: memberships.role }).from(memberships).where(and( + eq(memberships.workspaceId, workspaceId), + eq(memberships.userId, request.user.sub), + )).limit(1) + + if (!membership) throw errors.forbidden('You are not a member of this workspace') + if (!can(membership.role, permission)) throw errors.forbidden() + return membership +} + +export async function requireProjectPermission(request: FastifyRequest, projectId: string, permission: Permission) { + const [project] = await db.select({ id: projects.id, workspaceId: projects.workspaceId }).from(projects).where(eq(projects.id, projectId)).limit(1) + if (!project) throw errors.notFound('Project') + const membership = await requireWorkspacePermission(request, project.workspaceId, permission) + return { project, membership } +} diff --git a/server/src/lib/realtime.ts b/server/src/lib/realtime.ts new file mode 100644 index 0000000..2747d69 --- /dev/null +++ b/server/src/lib/realtime.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import type { Redis } from 'ioredis' +import { createRedisConnection, redis } from '../queue/connection.js' + +export const REALTIME_CHANNEL = 'frameflow:realtime:v1' + +export interface RealtimeEvent { + id: string + workspaceId: string + userId?: string + type: string + resourceType?: string + resourceId?: string + occurredAt: string + payload: Record +} + +export type RealtimeEventInput = Omit & { + id?: string + occurredAt?: string +} + +export function normalizeRealtimeEvent(input: RealtimeEventInput): RealtimeEvent { + return { + id: input.id ?? randomUUID(), + workspaceId: input.workspaceId, + ...(input.userId ? { userId: input.userId } : {}), + type: input.type, + ...(input.resourceType ? { resourceType: input.resourceType } : {}), + ...(input.resourceId ? { resourceId: input.resourceId } : {}), + occurredAt: input.occurredAt ?? new Date().toISOString(), + payload: input.payload, + } +} + +export async function publishRealtimeEvent(input: RealtimeEventInput) { + const event = normalizeRealtimeEvent(input) + try { + await redis.publish(REALTIME_CHANNEL, JSON.stringify(event)) + return true + } catch { + return false + } +} + +interface RealtimeClient { + workspaceId: string + userId: string + send: (event: RealtimeEvent) => void + close: () => void +} + +export class RealtimeHub { + private readonly subscriber: Redis + private readonly clients = new Set() + private readonly ready: Promise + + constructor(onError: (error: Error) => void = () => {}) { + this.subscriber = createRedisConnection() + this.subscriber.on('error', onError) + this.subscriber.on('message', (_channel, message) => { + let event: RealtimeEvent + try { + event = JSON.parse(message) as RealtimeEvent + } catch { + return + } + if (!event.workspaceId || !event.type || !event.id) return + for (const client of this.clients) { + if (client.workspaceId !== event.workspaceId) continue + if (event.userId && client.userId !== event.userId) continue + client.send(event) + } + }) + this.ready = this.subscriber.subscribe(REALTIME_CHANNEL) + } + + async addClient(client: RealtimeClient) { + await this.ready + this.clients.add(client) + return () => this.clients.delete(client) + } + + async close() { + for (const client of this.clients) client.close() + this.clients.clear() + await this.ready.catch(() => undefined) + await this.subscriber.quit().catch(() => undefined) + } +} diff --git a/server/src/lib/remote-media.test.ts b/server/src/lib/remote-media.test.ts new file mode 100644 index 0000000..341103a --- /dev/null +++ b/server/src/lib/remote-media.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import { downloadRemoteMedia, type ResolveHost } from './remote-media.js' + +const publicResolver: ResolveHost = async () => [{ address: '93.184.216.34', family: 4 }] + +function options(overrides: Partial[1]> = {}) { + return { + maxBytes: 1024, + timeoutMs: 1000, + maxRedirects: 2, + resolveHost: publicResolver, + ...overrides, + } +} + +describe('remote provider media download', () => { + it('downloads a public HTTPS response with manual redirect handling and a byte limit', async () => { + const fetchMock = vi.fn(async (_url, init) => { + expect(init).toMatchObject({ method: 'GET', redirect: 'manual' }) + expect(init?.signal).toBeInstanceOf(AbortSignal) + return new Response(Buffer.from('generated-media'), { + status: 200, + headers: { 'content-length': '15', 'content-type': 'video/mp4' }, + }) + }) + + const result = await downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock })) + + expect(result).toEqual(Buffer.from('generated-media')) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it.each([ + 'http://provider.example/output.mp4', + 'https://localhost/output.mp4', + 'https://127.0.0.1/output.mp4', + 'https://10.2.3.4/output.mp4', + 'https://[::1]/output.mp4', + 'https://[fc00::1]/output.mp4', + ])('rejects a non-public source before fetching: %s', async (url) => { + const fetchMock = vi.fn() + await expect(downloadRemoteMedia(url, options({ fetchImpl: fetchMock }))).rejects.toMatchObject({ code: 'BAD_REQUEST' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('rejects DNS answers containing a private address', async () => { + const resolveHost: ResolveHost = async () => [ + { address: '93.184.216.34', family: 4 }, + { address: '169.254.169.254', family: 4 }, + ] + const fetchMock = vi.fn() + + await expect(downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock, resolveHost }))) + .rejects.toThrow('non-public address') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('validates every redirect and blocks a redirect to metadata services', async () => { + const fetchMock = vi.fn(async () => new Response(null, { + status: 302, + headers: { location: 'https://169.254.169.254/latest/meta-data/' }, + })) + + await expect(downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock }))) + .rejects.toThrow('non-public address') + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('follows a bounded public redirect and resolves relative destinations', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 307, headers: { location: '/signed/output.mp4' } })) + .mockResolvedValueOnce(new Response(Buffer.from('redirected'), { status: 200 })) + + const result = await downloadRemoteMedia('https://provider.example/start', options({ fetchImpl: fetchMock })) + + expect(result).toEqual(Buffer.from('redirected')) + expect(String(fetchMock.mock.calls[1]?.[0])).toBe('https://provider.example/signed/output.mp4') + }) + + it('rejects a declared size above the limit before reading the body', async () => { + const fetchMock = vi.fn(async () => new Response(Buffer.from('small-body'), { + status: 200, + headers: { 'content-length': '2048' }, + })) + + await expect(downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock }))) + .rejects.toThrow('exceeds the configured storage limit') + }) + + it('stops a chunked response as soon as the streamed bytes exceed the limit', async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.alloc(700)) + controller.enqueue(Buffer.alloc(700)) + controller.close() + }, + }) + const fetchMock = vi.fn(async () => new Response(body, { status: 200 })) + + await expect(downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock }))) + .rejects.toThrow('exceeds the configured storage limit') + }) + + it('aborts a stalled provider response at the configured timeout', async () => { + const fetchMock = vi.fn(async (_url, init) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }) + })) + + await expect(downloadRemoteMedia('https://provider.example/output.mp4', options({ fetchImpl: fetchMock, timeoutMs: 10 }))) + .rejects.toThrow() + }) +}) diff --git a/server/src/lib/remote-media.ts b/server/src/lib/remote-media.ts new file mode 100644 index 0000000..69c4127 --- /dev/null +++ b/server/src/lib/remote-media.ts @@ -0,0 +1,130 @@ +import { lookup } from 'node:dns/promises' +import ipaddr from 'ipaddr.js' +import { errors } from './errors.js' + +export interface ResolvedAddress { + address: string + family: number +} + +export type ResolveHost = (hostname: string) => Promise + +export interface DownloadRemoteMediaOptions { + maxBytes: number + timeoutMs: number + maxRedirects: number + fetchImpl?: typeof fetch + resolveHost?: ResolveHost +} + +const redirectStatuses = new Set([301, 302, 303, 307, 308]) + +async function defaultResolveHost(hostname: string) { + return lookup(hostname, { all: true, verbatim: true }) +} + +function publicAddress(address: string) { + try { + return ipaddr.process(address).range() === 'unicast' + } catch { + return false + } +} + +async function validateRemoteUrl(value: string | URL, resolveHost: ResolveHost) { + let url: URL + try { + url = value instanceof URL ? value : new URL(value) + } catch { + throw errors.badRequest('Provider returned an invalid media URL') + } + if (url.protocol !== 'https:') throw errors.badRequest('Provider media URL must use HTTPS') + if (url.username || url.password) throw errors.badRequest('Provider media URL must not contain credentials') + + const rawHostname = url.hostname.toLowerCase().replace(/\.$/, '') + const hostname = rawHostname.startsWith('[') && rawHostname.endsWith(']') + ? rawHostname.slice(1, -1) + : rawHostname + if (!hostname || hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal')) { + throw errors.badRequest('Provider media URL resolves to a disallowed host') + } + + let addresses: ResolvedAddress[] + if (ipaddr.isValid(hostname)) { + addresses = [{ address: hostname, family: ipaddr.parse(hostname).kind() === 'ipv4' ? 4 : 6 }] + } else { + try { + addresses = await resolveHost(hostname) + } catch { + throw errors.badRequest('Provider media URL could not be resolved') + } + } + if (addresses.length === 0 || addresses.some(({ address }) => !publicAddress(address))) { + throw errors.badRequest('Provider media URL resolves to a non-public address') + } + return url +} + +async function readLimitedBody(response: Response, maxBytes: number) { + const contentLength = response.headers.get('content-length') + if (contentLength !== null) { + const declaredSize = Number(contentLength) + if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) { + await response.body?.cancel() + throw errors.badRequest('Provider media has an invalid content length') + } + if (declaredSize > maxBytes) { + await response.body?.cancel() + throw errors.badRequest('Generated media exceeds the configured storage limit') + } + } + + if (!response.body) throw errors.badRequest('Provider returned empty media') + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + if (!value) continue + if (total + value.byteLength > maxBytes) { + await reader.cancel() + throw errors.badRequest('Generated media exceeds the configured storage limit') + } + chunks.push(value) + total += value.byteLength + } + if (total === 0) throw errors.badRequest('Provider returned empty media') + return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), total) +} + +export async function downloadRemoteMedia(value: string, options: DownloadRemoteMediaOptions) { + const fetchImpl = options.fetchImpl ?? fetch + const resolveHost = options.resolveHost ?? defaultResolveHost + const signal = AbortSignal.timeout(options.timeoutMs) + let url = await validateRemoteUrl(value, resolveHost) + + for (let redirectCount = 0; redirectCount <= options.maxRedirects; redirectCount += 1) { + const response = await fetchImpl(url, { + method: 'GET', + redirect: 'manual', + signal, + headers: { Accept: 'image/*, audio/*, video/*, application/octet-stream' }, + }) + if (redirectStatuses.has(response.status)) { + const location = response.headers.get('location') + await response.body?.cancel() + if (!location) throw errors.badRequest('Provider media redirect is missing a destination') + if (redirectCount === options.maxRedirects) throw errors.badRequest('Provider media exceeded the redirect limit') + url = await validateRemoteUrl(new URL(location, url), resolveHost) + continue + } + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Failed to download provider output (${response.status})`) + } + return readLimitedBody(response, options.maxBytes) + } + + throw errors.badRequest('Provider media exceeded the redirect limit') +} diff --git a/server/src/lib/slug.ts b/server/src/lib/slug.ts new file mode 100644 index 0000000..8fcc9ad --- /dev/null +++ b/server/src/lib/slug.ts @@ -0,0 +1,11 @@ +export function slugify(input: string) { + const ascii = input + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 72) + + return ascii || `project-${Date.now().toString(36)}` +} diff --git a/server/src/lib/stripe-billing.test.ts b/server/src/lib/stripe-billing.test.ts new file mode 100644 index 0000000..022c06e --- /dev/null +++ b/server/src/lib/stripe-billing.test.ts @@ -0,0 +1,69 @@ +import Stripe from 'stripe' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { config } from '../config.js' +import { StripeSdkBillingProvider } from './stripe-billing.js' + +describe.sequential('Stripe SDK billing provider', () => { + const keys = [ + 'STRIPE_SECRET_KEY', + 'STRIPE_WEBHOOK_SECRET', + 'STRIPE_PRO_PRICE_ID', + 'STRIPE_STUDIO_PRICE_ID', + ] as const + const original = Object.fromEntries(keys.map((key) => [key, config[key]])) + + beforeAll(() => { + Object.assign(config, { + STRIPE_SECRET_KEY: 'sk_test_frameflow_contract', + STRIPE_WEBHOOK_SECRET: 'whsec_frameflow_contract', + STRIPE_PRO_PRICE_ID: 'price_pro_contract', + STRIPE_STUDIO_PRICE_ID: 'price_studio_contract', + }) + }) + + afterAll(() => { + Object.assign(config, original) + }) + + it('reports checkout readiness and keeps price mapping server-side', () => { + const provider = new StripeSdkBillingProvider() + expect(provider.readiness()).toMatchObject({ + provider: 'stripe', + status: 'CONFIGURED', + portalConfigured: true, + webhookConfigured: true, + plans: { + PRO: { checkoutAvailable: true }, + STUDIO: { checkoutAvailable: true }, + }, + }) + expect(provider.planForPriceId('price_pro_contract')).toBe('PRO') + expect(provider.planForPriceId('price_studio_contract')).toBe('STUDIO') + expect(provider.planForPriceId('price_unknown')).toBeNull() + }) + + it('uses the official Stripe signature verifier against the exact payload bytes', () => { + const provider = new StripeSdkBillingProvider() + const payload = JSON.stringify({ + id: 'evt_contract', + object: 'event', + api_version: null, + created: 1_800_000_000, + data: { object: { id: 'sub_contract' } }, + livemode: false, + pending_webhooks: 1, + request: null, + type: 'customer.subscription.updated', + }) + const stripe = new Stripe(config.STRIPE_SECRET_KEY!) + const signature = stripe.webhooks.generateTestHeaderString({ + payload, + secret: config.STRIPE_WEBHOOK_SECRET!, + timestamp: 1_800_000_000, + }) + + const event = provider.constructWebhookEvent(Buffer.from(payload), signature) + expect(event.id).toBe('evt_contract') + expect(() => provider.constructWebhookEvent(Buffer.from(`${payload} `), signature)).toThrow() + }) +}) diff --git a/server/src/lib/stripe-billing.ts b/server/src/lib/stripe-billing.ts new file mode 100644 index 0000000..84d0d30 --- /dev/null +++ b/server/src/lib/stripe-billing.ts @@ -0,0 +1,194 @@ +import Stripe from 'stripe' +import { config } from '../config.js' +import { errors } from './errors.js' +import type { PurchasableBillingPlan } from './billing-plans.js' + +const purchasablePlans: PurchasableBillingPlan[] = ['PRO', 'STUDIO'] + +export interface BillingProviderReadiness { + provider: 'stripe' + status: 'NOT_CONFIGURED' | 'PARTIAL' | 'CONFIGURED' + portalConfigured: boolean + webhookConfigured: boolean + missingRequirements: string[] + plans: Record +} + +export interface StripeBillingProvider { + readonly name: 'stripe' + readiness(): BillingProviderReadiness + planForPriceId(priceId: string): PurchasableBillingPlan | null + createCustomer(input: { + workspaceId: string + workspaceName: string + email: string + displayName: string + }): Promise<{ id: string }> + createCheckoutSession(input: { + workspaceId: string + customerId: string + plan: PurchasableBillingPlan + idempotencyKey: string + }): Promise<{ id: string; url: string }> + createPortalSession(input: { + customerId: string + }): Promise<{ id: string; url: string }> + constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event + retrieveSubscription(subscriptionId: string): Promise +} + +function compact(values: Array) { + return values.filter((value): value is string => Boolean(value)) +} + +function returnUrl(kind: 'success' | 'cancel' | 'portal') { + const configured = kind === 'success' + ? config.BILLING_SUCCESS_URL + : kind === 'cancel' + ? config.BILLING_CANCEL_URL + : undefined + const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173' + const url = new URL(configured ?? origin) + url.searchParams.set('billing', kind) + if (kind === 'success') { + url.searchParams.set('session_id', 'FRAMEFLOW_CHECKOUT_SESSION') + return url.toString().replace('FRAMEFLOW_CHECKOUT_SESSION', '{CHECKOUT_SESSION_ID}') + } + return url.toString() +} + +export class StripeSdkBillingProvider implements StripeBillingProvider { + readonly name = 'stripe' as const + private stripeClient: Stripe | null = null + + private priceId(plan: PurchasableBillingPlan) { + return plan === 'PRO' ? config.STRIPE_PRO_PRICE_ID : config.STRIPE_STUDIO_PRICE_ID + } + + private client() { + if (!config.STRIPE_SECRET_KEY) { + throw errors.serviceUnavailable('Stripe billing is not configured', { + missingRequirements: ['STRIPE_SECRET_KEY'], + }) + } + this.stripeClient ??= new Stripe(config.STRIPE_SECRET_KEY) + return this.stripeClient + } + + readiness(): BillingProviderReadiness { + const baseMissing = compact([ + config.STRIPE_SECRET_KEY ? null : 'STRIPE_SECRET_KEY', + config.STRIPE_WEBHOOK_SECRET ? null : 'STRIPE_WEBHOOK_SECRET', + ]) + const plans = Object.fromEntries(purchasablePlans.map((plan) => { + const missingRequirements = [...baseMissing, ...(this.priceId(plan) ? [] : [`STRIPE_${plan}_PRICE_ID`])] + return [plan, { checkoutAvailable: missingRequirements.length === 0, missingRequirements }] + })) as BillingProviderReadiness['plans'] + const configuredPlans = purchasablePlans.filter((plan) => plans[plan].checkoutAvailable).length + const hasAnyConfiguration = Boolean( + config.STRIPE_SECRET_KEY + || config.STRIPE_WEBHOOK_SECRET + || config.STRIPE_PRO_PRICE_ID + || config.STRIPE_STUDIO_PRICE_ID, + ) + return { + provider: 'stripe', + status: configuredPlans === purchasablePlans.length + ? 'CONFIGURED' + : hasAnyConfiguration + ? 'PARTIAL' + : 'NOT_CONFIGURED', + portalConfigured: Boolean(config.STRIPE_SECRET_KEY), + webhookConfigured: Boolean(config.STRIPE_SECRET_KEY && config.STRIPE_WEBHOOK_SECRET), + missingRequirements: baseMissing, + plans, + } + } + + planForPriceId(priceId: string) { + return purchasablePlans.find((plan) => this.priceId(plan) === priceId) ?? null + } + + async createCustomer(input: { + workspaceId: string + workspaceName: string + email: string + displayName: string + }) { + const customer = await this.client().customers.create({ + email: input.email, + name: input.workspaceName, + description: `${input.workspaceName} · ${input.displayName}`, + metadata: { frameflowWorkspaceId: input.workspaceId }, + }, { idempotencyKey: `frameflow-customer:${input.workspaceId}` }) + return { id: customer.id } + } + + async createCheckoutSession(input: { + workspaceId: string + customerId: string + plan: PurchasableBillingPlan + idempotencyKey: string + }) { + const readiness = this.readiness().plans[input.plan] + const priceId = this.priceId(input.plan) + if (!readiness.checkoutAvailable || !priceId) { + throw errors.serviceUnavailable(`${input.plan} checkout is not configured`, { + missingRequirements: readiness.missingRequirements, + }) + } + const session = await this.client().checkout.sessions.create({ + mode: 'subscription', + customer: input.customerId, + client_reference_id: input.workspaceId, + line_items: [{ price: priceId, quantity: 1 }], + allow_promotion_codes: true, + success_url: returnUrl('success'), + cancel_url: returnUrl('cancel'), + metadata: { + frameflowWorkspaceId: input.workspaceId, + frameflowPlan: input.plan, + }, + subscription_data: { + metadata: { + frameflowWorkspaceId: input.workspaceId, + frameflowPlan: input.plan, + }, + }, + }, { idempotencyKey: input.idempotencyKey }) + if (!session.url) throw new Error('Stripe Checkout returned no redirect URL') + return { id: session.id, url: session.url } + } + + async createPortalSession(input: { customerId: string }) { + const client = this.client() + const session = await client.billingPortal.sessions.create({ + customer: input.customerId, + return_url: returnUrl('portal'), + ...(config.STRIPE_PORTAL_CONFIGURATION_ID + ? { configuration: config.STRIPE_PORTAL_CONFIGURATION_ID } + : {}), + }) + return { id: session.id, url: session.url } + } + + constructWebhookEvent(payload: Buffer, signature: string) { + if (!config.STRIPE_WEBHOOK_SECRET) { + throw errors.serviceUnavailable('Stripe webhooks are not configured', { + missingRequirements: ['STRIPE_WEBHOOK_SECRET'], + }) + } + return this.client().webhooks.constructEvent(payload, signature, config.STRIPE_WEBHOOK_SECRET) + } + + retrieveSubscription(subscriptionId: string) { + return this.client().subscriptions.retrieve(subscriptionId) + } +} + +export function createStripeBillingProvider(): StripeBillingProvider { + return new StripeSdkBillingProvider() +} diff --git a/server/src/media/ffmpeg.test.ts b/server/src/media/ffmpeg.test.ts new file mode 100644 index 0000000..d7e86e5 --- /dev/null +++ b/server/src/media/ffmpeg.test.ts @@ -0,0 +1,117 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { config } from '../config.js' +import { buildSubtitleDocument, composeEpisode } from './ffmpeg.js' + +const execFileAsync = promisify(execFile) +const temporaryDirectories: string[] = [] + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +describe('composeEpisode', () => { + it('normalizes shots, burns subtitles, mixes music and produces a playable MP4', async () => { + const directory = await mkdtemp(join(tmpdir(), 'frameflow-ffmpeg-test-')) + temporaryDirectories.push(directory) + const imagePath = join(directory, 'frame.jpg') + const audioPath = join(directory, 'voice.wav') + const videoPath = join(directory, 'source.mp4') + await execFileAsync(config.FFMPEG_PATH, [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'color=c=0x34515e:s=640x360', + '-frames:v', '1', imagePath, + ]) + await execFileAsync(config.FFMPEG_PATH, [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-t', '2', audioPath, + ]) + await execFileAsync(config.FFMPEG_PATH, [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=24', + '-t', '2', '-pix_fmt', 'yuv420p', videoPath, + ]) + const image = await readFile(imagePath) + const audio = await readFile(audioPath) + const video = await readFile(videoPath) + + const result = await composeEpisode({ + clips: [ + { + durationMs: 800, + subtitleText: 'Scene one', + video: { data: video, extension: 'mp4' }, + audio: { data: audio, extension: 'wav' }, + production: { + trimStartMs: 250, + playbackRate: 1.25, + audioVolume: 0.6, + fit: 'cover', + transition: 'fade', + transitionDurationMs: 200, + }, + }, + { durationMs: 800, subtitleText: 'Scene two', image: { data: image, extension: 'jpg' } }, + ], + settings: { + width: 640, + height: 360, + fps: 24, + quality: 'draft', + burnSubtitles: true, + normalizeAudio: true, + musicVolume: 0.1, + subtitleStyle: { + fontSize: 22, + textColor: '#F2F2F2', + outlineColor: '#111111', + outlineWidth: 2, + bottomMargin: 36, + bold: true, + }, + }, + backgroundMusic: { data: audio, extension: 'wav' }, + }) + + expect(result.durationMs).toBe(1600) + expect(result.data.length).toBeGreaterThan(1000) + const outputPath = join(directory, 'result.mp4') + await writeFile(outputPath, result.data) + const probe = await execFileAsync(config.FFPROBE_PATH, [ + '-v', 'error', '-show_entries', 'format=duration', '-of', 'json', outputPath, + ]) + const duration = Number((JSON.parse(probe.stdout) as { format: { duration: string } }).format.duration) + expect(duration).toBeGreaterThan(1.4) + expect(duration).toBeLessThan(1.9) + }, 30_000) +}) + +describe('buildSubtitleDocument', () => { + const clips = [ + { durationMs: 1_250, subtitleText: '第一句' }, + { durationMs: 750, subtitleText: '' }, + { durationMs: 2_005, subtitleText: '第二句 --> 安全箭头' }, + ] + + it('builds SubRip cues on the complete shot timeline', () => { + expect(buildSubtitleDocument(clips, 'srt')).toBe( + '1\n00:00:00,000 --> 00:00:01,250\n第一句\n\n2\n00:00:02,000 --> 00:00:04,005\n第二句 -> 安全箭头\n', + ) + }) + + it('builds WebVTT cues with the required header', () => { + expect(buildSubtitleDocument(clips, 'vtt')).toBe( + 'WEBVTT\n\n00:00:00.000 --> 00:00:01.250\n第一句\n\n00:00:02.000 --> 00:00:04.005\n第二句 -> 安全箭头\n', + ) + }) + + it('does not create an empty sidecar when every cue is blank', () => { + expect(buildSubtitleDocument([{ durationMs: 1000, subtitleText: ' ' }], 'srt')).toBe('') + expect(buildSubtitleDocument([{ durationMs: 1000, subtitleText: '' }], 'vtt')).toBe('') + }) +}) diff --git a/server/src/media/ffmpeg.ts b/server/src/media/ffmpeg.ts new file mode 100644 index 0000000..76981d0 --- /dev/null +++ b/server/src/media/ffmpeg.ts @@ -0,0 +1,322 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, readFile, rm, writeFile, copyFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { z } from 'zod' +import { config } from '../config.js' + +export const episodeRenderSettingsSchema = z.object({ + width: z.number().int().min(320).max(4096).default(1920), + height: z.number().int().min(320).max(4096).default(1080), + fps: z.union([z.literal(24), z.literal(25), z.literal(30), z.literal(50), z.literal(60)]).default(24), + quality: z.enum(['draft', 'balanced', 'high']).default('balanced'), + burnSubtitles: z.boolean().default(true), + normalizeAudio: z.boolean().default(true), + backgroundMusicStorageKey: z.string().min(1).max(1000).optional(), + musicVolume: z.number().min(0).max(1).default(0.18), + subtitleStyle: z.object({ + fontSize: z.number().int().min(12).max(72).default(24), + textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#FFFFFF'), + outlineColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).default('#000000'), + outlineWidth: z.number().min(0).max(6).default(2), + bottomMargin: z.number().int().min(12).max(300).default(48), + bold: z.boolean().default(true), + }).default({ + fontSize: 24, + textColor: '#FFFFFF', + outlineColor: '#000000', + outlineWidth: 2, + bottomMargin: 48, + bold: true, + }), +}) + +export type EpisodeRenderSettings = z.infer + +const shotProductionSettingsShape = { + trimStartMs: z.number().int().min(0).max(600_000), + playbackRate: z.number().min(0.25).max(4), + audioVolume: z.number().min(0).max(2), + fit: z.enum(['contain', 'cover']), + transition: z.enum(['cut', 'fade']), + transitionDurationMs: z.number().int().min(0).max(2_000), +} + +export const shotProductionSettingsSchema = z.object({ + trimStartMs: shotProductionSettingsShape.trimStartMs.default(0), + playbackRate: shotProductionSettingsShape.playbackRate.default(1), + audioVolume: shotProductionSettingsShape.audioVolume.default(1), + fit: shotProductionSettingsShape.fit.default('contain'), + transition: shotProductionSettingsShape.transition.default('cut'), + transitionDurationMs: shotProductionSettingsShape.transitionDurationMs.default(350), +}) + +export const shotProductionSettingsUpdateSchema = z.object(shotProductionSettingsShape).partial() +export type ShotProductionSettings = z.infer + +export interface CompositionClip { + durationMs: number + subtitleText: string + production?: ShotProductionSettings + video?: { data: Buffer; extension: string } + image?: { data: Buffer; extension: string } + audio?: { data: Buffer; extension: string } +} + +export interface EpisodeCompositionInput { + clips: CompositionClip[] + settings: EpisodeRenderSettings + backgroundMusic?: { data: Buffer; extension: string } +} + +const crfByQuality = { draft: '26', balanced: '21', high: '18' } as const +let subtitleFilterAvailable: boolean | undefined + +function run(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { + stderr = `${stderr}${chunk}`.slice(-32_000) + }) + child.once('error', reject) + child.once('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited with code ${code}: ${stderr.trim()}`)) + }) + }) +} + +function capture(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + const append = (chunk: Buffer | string) => { + output = `${output}${chunk.toString()}`.slice(-2_000_000) + } + child.stdout.on('data', append) + child.stderr.on('data', append) + child.once('error', reject) + child.once('close', (code) => { + if (code === 0) resolve(output) + else reject(new Error(`${command} exited with code ${code}: ${output.slice(-32_000).trim()}`)) + }) + }) +} + +async function hasSubtitleFilter(cwd: string) { + if (subtitleFilterAvailable !== undefined) return subtitleFilterAvailable + try { + const filters = await capture(config.FFMPEG_PATH, ['-hide_banner', '-filters'], cwd) + subtitleFilterAvailable = /^\s*[TSC.]{3}\s+subtitles\s/m.test(filters) + } catch { + subtitleFilterAvailable = false + } + return subtitleFilterAvailable +} + +export type SubtitleFormat = 'srt' | 'vtt' + +function subtitleTimestamp(milliseconds: number, format: SubtitleFormat) { + const total = Math.max(0, Math.round(milliseconds)) + const hours = Math.floor(total / 3_600_000) + const minutes = Math.floor((total % 3_600_000) / 60_000) + const seconds = Math.floor((total % 60_000) / 1000) + const millis = total % 1000 + const separator = format === 'srt' ? ',' : '.' + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}${separator}${String(millis).padStart(3, '0')}` +} + +export function buildSubtitleDocument( + clips: Array>, + format: SubtitleFormat, +) { + let cursor = 0 + const entries: string[] = [] + for (const clip of clips) { + const text = clip.subtitleText.trim().replace(/\r/g, '').replace(/-->/g, '->') + const start = cursor + cursor += clip.durationMs + if (!text) continue + const sequence = format === 'srt' ? `${entries.length + 1}\n` : '' + entries.push(`${sequence}${subtitleTimestamp(start, format)} --> ${subtitleTimestamp(cursor, format)}\n${text}\n`) + } + if (entries.length === 0) return '' + const body = entries.join('\n') + return format === 'vtt' ? `WEBVTT\n\n${body}` : body +} + +function escapeFilterPath(path: string) { + return path.replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'").replace(/,/g, '\\,') +} + +function assColor(value: string) { + const red = value.slice(1, 3) + const green = value.slice(3, 5) + const blue = value.slice(5, 7) + return `&H00${blue}${green}${red}` +} + +function subtitleForceStyle(settings: EpisodeRenderSettings['subtitleStyle']) { + return [ + `FontSize=${settings.fontSize}`, + `PrimaryColour=${assColor(settings.textColor)}`, + `OutlineColour=${assColor(settings.outlineColor)}`, + `Outline=${settings.outlineWidth}`, + 'Shadow=0', + `MarginV=${settings.bottomMargin}`, + `Bold=${settings.bold ? -1 : 0}`, + 'Alignment=2', + ].join(',') +} + +function mediaExtension(value: string) { + const safe = value.toLowerCase().replace(/[^a-z0-9]/g, '') + if (!safe) throw new Error('Media extension is invalid') + return safe +} + +export async function composeEpisode(input: EpisodeCompositionInput) { + if (input.clips.length === 0) throw new Error('At least one shot is required to compose an episode') + const directory = await mkdtemp(join(tmpdir(), 'frameflow-render-')) + const normalizedPaths: string[] = [] + const durationMs = input.clips.reduce((total, clip) => total + clip.durationMs, 0) + + try { + for (const [index, clip] of input.clips.entries()) { + if (!clip.video && !clip.image) throw new Error(`Shot ${index + 1} has no video or image source`) + const production = shotProductionSettingsSchema.parse(clip.production ?? {}) + const visual = clip.video ?? clip.image! + const visualPath = join(directory, `visual-${index}.${mediaExtension(visual.extension)}`) + await writeFile(visualPath, visual.data) + + const audioPath = clip.audio + ? join(directory, `audio-${index}.${mediaExtension(clip.audio.extension)}`) + : null + if (audioPath && clip.audio) await writeFile(audioPath, clip.audio.data) + + const outputPath = join(directory, `clip-${String(index).padStart(4, '0')}.mp4`) + const seconds = Math.max(0.1, clip.durationMs / 1000) + const visualArgs = clip.video + ? [ + ...(production.trimStartMs > 0 ? ['-ss', String(production.trimStartMs / 1000)] : []), + '-i', visualPath, + ] + : ['-loop', '1', '-framerate', String(input.settings.fps), '-i', visualPath] + const audioArgs = audioPath + ? ['-i', audioPath] + : ['-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=48000'] + const videoFilters = [ + clip.video && production.playbackRate !== 1 + ? `setpts=(PTS-STARTPTS)/${production.playbackRate}` + : 'setpts=PTS-STARTPTS', + production.fit === 'cover' + ? `scale=${input.settings.width}:${input.settings.height}:force_original_aspect_ratio=increase,crop=${input.settings.width}:${input.settings.height}` + : `scale=${input.settings.width}:${input.settings.height}:force_original_aspect_ratio=decrease,pad=${input.settings.width}:${input.settings.height}:(ow-iw)/2:(oh-ih)/2:black`, + `fps=${input.settings.fps}`, + `tpad=stop_mode=clone:stop_duration=${seconds}`, + `trim=duration=${seconds}`, + 'setpts=PTS-STARTPTS', + ] + const audioFilters = [ + 'aresample=48000', + `volume=${production.audioVolume}`, + `apad=pad_dur=${seconds}`, + `atrim=duration=${seconds}`, + 'asetpts=PTS-STARTPTS', + ] + if (production.transition === 'fade') { + const transitionSeconds = Math.min(production.transitionDurationMs / 1000, seconds / 2) + if (transitionSeconds > 0) { + const fadeOutStart = Math.max(0, seconds - transitionSeconds) + videoFilters.push(`fade=t=in:st=0:d=${transitionSeconds}`, `fade=t=out:st=${fadeOutStart}:d=${transitionSeconds}`) + audioFilters.push(`afade=t=in:st=0:d=${transitionSeconds}`, `afade=t=out:st=${fadeOutStart}:d=${transitionSeconds}`) + } + } + videoFilters.push('format=yuv420p') + const filter = `[0:v]${videoFilters.join(',')}[v];[1:a]${audioFilters.join(',')}[a]` + + await run(config.FFMPEG_PATH, [ + '-hide_banner', '-loglevel', 'error', '-y', + ...visualArgs, + ...audioArgs, + '-filter_complex', filter, + '-map', '[v]', '-map', '[a]', + '-t', String(seconds), + '-c:v', 'libx264', '-preset', 'medium', '-crf', crfByQuality[input.settings.quality], + '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-ac', '2', + '-movflags', '+faststart', outputPath, + ], directory) + normalizedPaths.push(outputPath) + } + + const concatList = join(directory, 'clips.txt') + await writeFile(concatList, normalizedPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join('\n')) + const concatenatedPath = join(directory, 'concatenated.mp4') + await run(config.FFMPEG_PATH, [ + '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'concat', '-safe', '0', '-i', concatList, + '-c', 'copy', '-movflags', '+faststart', concatenatedPath, + ], directory) + + const subtitlePath = join(directory, 'subtitles.srt') + const subtitles = buildSubtitleDocument(input.clips, 'srt') + if (subtitles) await writeFile(subtitlePath, subtitles) + + const musicPath = input.backgroundMusic + ? join(directory, `music.${mediaExtension(input.backgroundMusic.extension)}`) + : null + if (musicPath && input.backgroundMusic) await writeFile(musicPath, input.backgroundMusic.data) + + const outputPath = join(directory, 'episode.mp4') + const burnSubtitles = input.settings.burnSubtitles && Boolean(subtitles) && await hasSubtitleFilter(directory) + const embedSubtitles = Boolean(subtitles) && !burnSubtitles + if (!burnSubtitles && !musicPath && !embedSubtitles && !input.settings.normalizeAudio) { + await copyFile(concatenatedPath, outputPath) + } else { + const args = ['-hide_banner', '-loglevel', 'error', '-y', '-i', concatenatedPath] + if (musicPath) args.push('-stream_loop', '-1', '-i', musicPath) + const subtitleInputIndex = musicPath ? 2 : 1 + if (embedSubtitles) args.push('-i', subtitlePath) + + const filters: string[] = [] + if (burnSubtitles) { + filters.push(`[0:v]subtitles=filename='${escapeFilterPath(subtitlePath)}':force_style='${subtitleForceStyle(input.settings.subtitleStyle)}'[v]`) + } + let audioInput = '[0:a]' + if (musicPath) { + const seconds = durationMs / 1000 + const fadeStart = Math.max(0, seconds - 2) + filters.push(`[1:a]volume=${input.settings.musicVolume},atrim=0:${seconds},afade=t=out:st=${fadeStart}:d=2[music]`) + filters.push('[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[mixed]') + audioInput = '[mixed]' + } + if (input.settings.normalizeAudio) { + filters.push(`${audioInput}loudnorm=I=-14:LRA=11:TP=-1.5[a]`) + audioInput = '[a]' + } + if (filters.length > 0) args.push('-filter_complex', filters.join(';')) + const audioFiltered = Boolean(musicPath) || input.settings.normalizeAudio + args.push('-map', burnSubtitles ? '[v]' : '0:v', '-map', audioFiltered ? audioInput : '0:a') + if (embedSubtitles) args.push('-map', `${subtitleInputIndex}:0`) + args.push( + '-c:v', burnSubtitles ? 'libx264' : 'copy', + ...(burnSubtitles ? ['-preset', 'medium', '-crf', crfByQuality[input.settings.quality]] : []), + '-c:a', audioFiltered ? 'aac' : 'copy', + '-b:a', '192k', + ...(embedSubtitles ? ['-c:s', 'mov_text', '-metadata:s:s:0', 'language=zho'] : []), + '-movflags', '+faststart', '-shortest', outputPath, + ) + await run(config.FFMPEG_PATH, args, directory) + } + + return { + data: await readFile(outputPath), + durationMs, + subtitlesMode: !subtitles ? 'none' as const : burnSubtitles ? 'burned' as const : 'embedded' as const, + } + } finally { + await rm(directory, { recursive: true, force: true }) + } +} diff --git a/server/src/media/timeline.test.ts b/server/src/media/timeline.test.ts new file mode 100644 index 0000000..294a126 --- /dev/null +++ b/server/src/media/timeline.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { clipTrimFromDrag, locateTimelinePosition, timelineDuration } from '../../../src/timeline.js' + +describe('timeline editing helpers', () => { + const shots = [{ durationMs: 1000 }, { durationMs: 2000 }, { durationMs: 500 }] + + it('locates exact episode positions across clip boundaries', () => { + expect(timelineDuration(shots)).toBe(3500) + expect(locateTimelinePosition(shots, 999)).toMatchObject({ index: 0, localMs: 999, beforeMs: 0 }) + expect(locateTimelinePosition(shots, 1000)).toMatchObject({ index: 1, localMs: 0, beforeMs: 1000 }) + expect(locateTimelinePosition(shots, 3500)).toMatchObject({ index: 2, localMs: 500, beforeMs: 3000 }) + expect(locateTimelinePosition(shots, -20)).toMatchObject({ index: 0, localMs: 0, globalMs: 0 }) + }) + + it('converts trim-handle movement into source in-points and output duration', () => { + const start = clipTrimFromDrag({ + edge: 'start', + deltaPixels: 10, + trackWidth: 100, + totalDurationMs: 3500, + durationMs: 1000, + trimStartMs: 250, + playbackRate: 1.5, + }) + expect(start).toEqual({ durationMs: 650, trimStartMs: 800 }) + + const restored = clipTrimFromDrag({ + edge: 'start', + deltaPixels: -100, + trackWidth: 100, + totalDurationMs: 3500, + durationMs: 1000, + trimStartMs: 250, + playbackRate: 1, + }) + expect(restored).toEqual({ durationMs: 1250, trimStartMs: 0 }) + + const end = clipTrimFromDrag({ + edge: 'end', + deltaPixels: -20, + trackWidth: 100, + totalDurationMs: 3500, + durationMs: 1000, + trimStartMs: 250, + playbackRate: 1, + }) + expect(end).toEqual({ durationMs: 300, trimStartMs: 250 }) + }) +}) diff --git a/server/src/providers/agnes.contract.test.ts b/server/src/providers/agnes.contract.test.ts new file mode 100644 index 0000000..a96d92c --- /dev/null +++ b/server/src/providers/agnes.contract.test.ts @@ -0,0 +1,318 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { config } from '../config.js' +import { AgnesProvider } from './agnes.js' + +const originalConfiguration = { + apiKey: config.AGNES_API_KEY, + textModel: config.AGNES_TEXT_MODEL, + imageModel: config.AGNES_IMAGE_MODEL, + videoModel: config.AGNES_VIDEO_MODEL, +} + +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } }) +} + +beforeAll(() => { + config.AGNES_API_KEY = 'contract-agnes-secret' + config.AGNES_TEXT_MODEL = 'agnes-text-contract' + config.AGNES_IMAGE_MODEL = 'agnes-image-contract' + config.AGNES_VIDEO_MODEL = 'agnes-video-contract' +}) + +afterAll(() => { + config.AGNES_API_KEY = originalConfiguration.apiKey + config.AGNES_TEXT_MODEL = originalConfiguration.textModel + config.AGNES_IMAGE_MODEL = originalConfiguration.imageModel + config.AGNES_VIDEO_MODEL = originalConfiguration.videoModel +}) + +describe('Agnes provider contract', () => { + it('generates a schema-validated script through chat completions', async () => { + const document = { + title: '雨夜来信', + logline: '记者在天台发现关键线索。', + estimatedDurationSeconds: 30, + scenes: [{ + number: 1, + location: '旧城天台', + interiorExterior: '外', + time: '夜', + summary: '林遥发现录音机。', + beats: [{ type: 'action', text: '雨水拍打铁门。', character: null, parenthetical: null }], + }], + } + const fetchMock = vi.fn(async (request, init) => { + expect(String(request)).toBe('https://api.agnes-ai.cn/v1/chat/completions') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer contract-agnes-secret') + const body = JSON.parse(init?.body as string) + expect(body).toMatchObject({ model: 'agnes-text-contract', response_format: { type: 'json_object' } }) + expect(body.messages).toHaveLength(2) + return jsonResponse({ + object: 'chat.completion', + choices: [{ index: 0, message: { role: 'assistant', content: JSON.stringify(document) }, finish_reason: 'stop' }], + usage: { prompt_tokens: 100, completion_tokens: 40, total_tokens: 140 }, + }) + }) + + const result = await new AgnesProvider(fetchMock).generateScript({ + instruction: '生成短剧', + projectName: '雾城来信', + genre: '悬疑', + visualBible: {}, + episodeTitle: '第一集', + synopsis: '调查线索', + targetDurationSeconds: 30, + safetyIdentifier: 'user-1', + }) + + expect(result).toMatchObject({ model: 'agnes-text-contract', document: { title: '雨夜来信' } }) + expect(result.usage).toEqual([ + { kind: 'TEXT_INPUT_TOKEN', quantity: 100, credits: 0.1 }, + { kind: 'TEXT_OUTPUT_TOKEN', quantity: 40, credits: 0.08 }, + ]) + }) + + it('generates an image without sending Agnes-unsupported response_format', async () => { + const fetchMock = vi.fn(async (_request, init) => { + const body = JSON.parse(init?.body as string) + expect(body).toMatchObject({ model: 'agnes-image-contract', prompt: expect.stringContaining('雨夜天台'), n: 1, size: '1536x864' }) + expect(body).not.toHaveProperty('response_format') + return jsonResponse({ data: [{ url: 'https://platform-outputs.agnes-ai.space/images/frame.webp' }] }) + }) + const result = await new AgnesProvider(fetchMock).generateImage({ + prompt: '雨夜天台', + negativePrompt: '文字水印', + width: 1536, + height: 864, + references: [], + referenceUrls: ['https://media.example/character.png'], + params: { response_format: 'b64_json', model: 'ignored' }, + safetyIdentifier: 'user-1', + }) + expect(result).toMatchObject({ + url: 'https://platform-outputs.agnes-ai.space/images/frame.webp', + sourceUrl: 'https://platform-outputs.agnes-ai.space/images/frame.webp', + mimeType: 'image/webp', + model: 'agnes-image-contract', + }) + }) + + it('uses the documented Agnes video request and result polling contracts', async () => { + let pollCount = 0 + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url.endsWith('/videos') && init?.method === 'POST') { + const body = JSON.parse(init.body as string) + expect(body).toMatchObject({ + model: 'agnes-video-contract', + prompt: '镜头缓慢推进', + image: 'https://media.example/source.png', + mode: 'ti2vid', + width: 1280, + height: 720, + num_frames: 121, + frame_rate: 24, + }) + expect(body).not.toHaveProperty('seconds') + expect(body).not.toHaveProperty('size') + expect(body).not.toHaveProperty('input_reference') + return jsonResponse({ task_id: 'task-contract', video_id: 'video-contract', status: 'queued', progress: 0 }) + } + if (url === 'https://api.agnes-ai.cn/agnesapi?video_id=video-contract' && init?.method === 'GET') { + pollCount += 1 + expect(new Headers(init.headers).get('authorization')).toBe('Bearer contract-agnes-secret') + return jsonResponse({ + id: 'task-contract', + status: 'completed', + progress: 100, + seconds: 5, + size: '1280x720', + metadata: { url: 'https://platform-outputs.agnes-ai.space/videos/agnes-video-v2.0/task-contract.mp4' }, + }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const result = await new AgnesProvider(fetchMock, { pollIntervalMs: 1, sleep: async () => undefined }).generateVideo({ + prompt: '镜头缓慢推进', + imageUrl: 'https://media.example/source.png', + imageData: Buffer.from('source-image'), + imageMimeType: 'image/png', + durationSeconds: 5, + params: { size: '1280x720' }, + }) + + expect(pollCount).toBe(1) + expect(result).toMatchObject({ + url: 'https://platform-outputs.agnes-ai.space/videos/agnes-video-v2.0/task-contract.mp4', + mimeType: 'video/mp4', + externalId: 'task-contract', + width: 1280, + height: 720, + usage: [{ kind: 'VIDEO_SECOND', quantity: 5, credits: 175 }], + }) + }) + + it('surfaces an Agnes result endpoint error', async () => { + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url.endsWith('/videos') && init?.method === 'POST') { + return jsonResponse({ task_id: 'task-invalid-content', video_id: 'video-invalid-content', status: 'queued', progress: 0 }) + } + if (url.includes('/agnesapi?') && init?.method === 'GET') { + return jsonResponse({ error: { message: 'upstream generation failed' } }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const request = new AgnesProvider(fetchMock, { pollIntervalMs: 1, sleep: async () => undefined }).generateVideo({ + prompt: 'test', + imageUrl: 'https://media.example/source.png', + durationSeconds: 4, + }) + + await expect(request).rejects.toThrow('Agnes video result retrieval failed: upstream generation failed') + }) + + it('backs off and continues when Agnes rate-limits result polling', async () => { + let pollCount = 0 + const sleeps: number[] = [] + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url.endsWith('/videos') && init?.method === 'POST') { + return jsonResponse({ task_id: 'task-rate-limit', video_id: 'video-rate-limit', status: 'queued' }) + } + if (url.includes('/agnesapi?') && init?.method === 'GET') { + pollCount += 1 + return jsonResponse({ error: { message: 'rate limited' } }, 429) + } + if (url.endsWith('/v1/videos/task-rate-limit') && init?.method === 'GET') { + pollCount += 1 + return jsonResponse({ + id: 'task-rate-limit', + status: 'completed', + metadata: { url: 'https://platform-outputs.agnes-ai.space/videos/complete.mp4' }, + }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const result = await new AgnesProvider(fetchMock, { + pollIntervalMs: 1, + predictionDeadlineSeconds: 120, + sleep: async (milliseconds) => { sleeps.push(milliseconds) }, + }).generateVideo({ + prompt: 'test', + imageUrl: 'https://media.example/source.png', + durationSeconds: 4, + }) + + expect(pollCount).toBe(2) + expect(sleeps).toEqual([1, 60_000, 1]) + expect(result.url).toBe('https://platform-outputs.agnes-ai.space/videos/complete.mp4') + }) + + it('falls back to the documented task_id result endpoint', async () => { + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url.endsWith('/videos') && init?.method === 'POST') { + const body = JSON.parse(init.body as string) + expect(body).toMatchObject({ width: 1280, height: 720 }) + return jsonResponse({ id: 'task-fallback', task_id: 'task-fallback', video_id: 'video-fallback', status: 'queued' }) + } + if (url === 'https://api.agnes-ai.cn/agnesapi?video_id=video-fallback') { + return jsonResponse({ id: 'task-fallback', status: 'in_progress', progress: 40 }) + } + if (url === 'https://api.agnes-ai.cn/v1/videos/task-fallback') { + return jsonResponse({ + id: 'task-fallback', + status: 'completed', + progress: 100, + metadata: { url: 'https://platform-outputs.agnes-ai.space/videos/fallback.mp4' }, + }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const result = await new AgnesProvider(fetchMock, { + pollIntervalMs: 1, + sleep: async () => undefined, + }).generateVideo({ + prompt: 'test', + imageUrl: 'https://media.example/source.png', + sourceWidth: 1536, + sourceHeight: 864, + durationSeconds: 4, + }) + + expect(result.url).toBe('https://platform-outputs.agnes-ai.space/videos/fallback.mp4') + }) + + it('accepts the top-level url returned by the live Agnes result endpoint', async () => { + let pollCount = 0 + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url.endsWith('/videos') && init?.method === 'POST') { + return jsonResponse({ id: 'task-live-shape', task_id: 'task-live-shape', video_id: 'video-live-shape', status: 'queued' }) + } + if (url.includes('/agnesapi?') && init?.method === 'GET') { + pollCount += 1 + return pollCount === 1 + ? jsonResponse({ id: 'video-live-shape', status: 'in_progress', progress: 30, url: null }) + : jsonResponse({ + id: 'video-live-shape', + status: 'completed', + progress: 100, + url: 'https://platform-outputs.agnes-ai.space/videos/live-shape.mp4', + }) + } + if (url.endsWith('/v1/videos/task-live-shape') && init?.method === 'GET') { + return jsonResponse({ + id: 'task-live-shape', + status: 'completed', + progress: 100, + url: 'https://platform-outputs.agnes-ai.space/videos/live-shape.mp4', + }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const result = await new AgnesProvider(fetchMock, { + pollIntervalMs: 1, + sleep: async () => undefined, + }).generateVideo({ + prompt: 'test', + imageUrl: 'https://media.example/source.png', + durationSeconds: 3, + }) + + expect(result.url).toBe('https://platform-outputs.agnes-ai.space/videos/live-shape.mp4') + }) + + it('rejects a non-public image URL before creating an Agnes video', async () => { + const fetchMock = vi.fn() + const request = new AgnesProvider(fetchMock).generateVideo({ + prompt: 'test', + imageUrl: 'http://127.0.0.1:9000/private.png', + durationSeconds: 4, + }) + + await expect(request).rejects.toThrow('Agnes image-to-video requires a publicly reachable HTTPS image URL') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('bounds provider failures without exposing the credential', async () => { + const fetchMock = vi.fn(async () => new Response(`failure contract-agnes-secret ${'x'.repeat(2000)}`, { status: 500 })) + const request = new AgnesProvider(fetchMock).generateImage({ + prompt: 'test', + width: 1024, + height: 1024, + references: [], + referenceUrls: [], + safetyIdentifier: 'user-1', + }) + await expect(request).rejects.toThrow(/^Agnes API 500:/) + await expect(request).rejects.not.toThrow('contract-agnes-secret') + }) +}) diff --git a/server/src/providers/agnes.ts b/server/src/providers/agnes.ts new file mode 100644 index 0000000..f45e9f7 --- /dev/null +++ b/server/src/providers/agnes.ts @@ -0,0 +1,467 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import { + scriptDocumentSchema, + scriptJsonSchema, + scriptToPlainText, + storyboardDocumentSchema, + storyboardJsonSchema, +} from './openai.js' +import type { + BinaryGenerationResult, + ImageGenerationInput, + ImageProvider, + LipsyncGenerationInput, + ScriptGenerationInput, + ScriptGenerationResult, + SpeechGenerationInput, + StoryboardGenerationInput, + StoryboardGenerationResult, + TextProvider, + VideoGenerationInput, + VideoProvider, +} from './types.js' + +export const agnesApiOrigin = 'https://api.agnes-ai.cn' +export const agnesApiBaseUrl = `${agnesApiOrigin}/v1` + +interface AgnesRuntimeOptions { + pollIntervalMs?: number + predictionDeadlineSeconds?: number + sleep?: (milliseconds: number) => Promise +} + +class AgnesProviderError extends Error { + constructor(message: string, readonly status?: number) { + super(message) + this.name = 'AgnesProviderError' + } +} + +class AgnesRateLimitError extends AgnesProviderError { + constructor(readonly retryAfterMs: number) { + super('Agnes video generation is temporarily rate limited', 429) + this.name = 'AgnesRateLimitError' + } +} + +const videoResponseSchema = z.object({ + id: z.string().trim().min(1).max(512).optional(), + task_id: z.string().trim().min(1).max(512).nullable().optional(), + video_id: z.string().trim().min(1).max(2048).nullable().optional(), + object: z.literal('video').optional(), + status: z.enum(['queued', 'in_progress', 'completed', 'failed']), + progress: z.number().finite().min(0).max(100).optional(), + seconds: z.union([z.string(), z.number()]).optional(), + size: z.string().trim().max(40).optional(), + url: z.string().url().nullable().optional(), + metadata: z.object({ + url: z.string().url().optional(), + }).passthrough().nullable().optional(), + error: z.object({ message: z.string().max(10_000) }).passthrough().nullable().optional(), +}).passthrough().refine((value) => Boolean(value.id || value.task_id || value.video_id), { + message: 'A video task identifier is required', +}) + +function requireApiKey() { + if (!config.AGNES_API_KEY) throw errors.badRequest('AGNES_API_KEY is not configured') + return config.AGNES_API_KEY +} + +function boundedErrorText(value: string) { + const secret = config.AGNES_API_KEY + return value.replace(secret ?? '', '[redacted]').slice(0, 1000) +} + +function retryAfterMilliseconds(response: Response) { + const value = response.headers.get('retry-after') + if (value) { + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return Math.max(1000, Math.min(300_000, seconds * 1000)) + const date = Date.parse(value) + if (Number.isFinite(date)) return Math.max(1000, Math.min(300_000, date - Date.now())) + } + return 60_000 +} + +function parseJsonContent(value: string, label: string) { + const normalized = value.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '') + try { + return JSON.parse(normalized) as unknown + } catch { + throw new AgnesProviderError(`Agnes returned malformed JSON for ${label}`) + } +} + +function messageContent(response: unknown) { + if (!response || typeof response !== 'object' || !('choices' in response) || !Array.isArray(response.choices)) { + throw new AgnesProviderError('Agnes returned an invalid chat response') + } + const message = response.choices[0] && typeof response.choices[0] === 'object' && 'message' in response.choices[0] + ? response.choices[0].message + : null + if (!message || typeof message !== 'object' || !('content' in message)) throw new AgnesProviderError('Agnes returned an empty chat response') + const content = message.content + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content.map((part) => part && typeof part === 'object' && 'text' in part && typeof part.text === 'string' ? part.text : '').join('') + } + throw new AgnesProviderError('Agnes returned an empty chat response') +} + +function usageRows(usage: unknown) { + if (!usage || typeof usage !== 'object') return [] + const input = 'prompt_tokens' in usage && typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : 0 + const output = 'completion_tokens' in usage && typeof usage.completion_tokens === 'number' ? usage.completion_tokens : 0 + return [ + ...(input > 0 ? [{ kind: 'TEXT_INPUT_TOKEN' as const, quantity: input, credits: input / 1000 }] : []), + ...(output > 0 ? [{ kind: 'TEXT_OUTPUT_TOKEN' as const, quantity: output, credits: output / 500 }] : []), + ] +} + +function imageMimeType(url: string) { + try { + const pathname = new URL(url).pathname.toLowerCase() + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg' + if (pathname.endsWith('.webp')) return 'image/webp' + } catch { + // The media downloader performs the authoritative URL validation. + } + return 'image/png' +} + +function normalizeFrameRate(value: unknown) { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 60 ? value : 24 +} + +function normalizeFrameCount(durationSeconds: number, frameRate: number, value: unknown) { + if (typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 441 && (value - 1) % 8 === 0) return value + const closest = Math.round((durationSeconds * frameRate - 1) / 8) * 8 + 1 + return Math.min(441, Math.max(1, closest)) +} + +function videoDimensions(input: VideoGenerationInput) { + const explicitWidth = input.params?.width + const explicitHeight = input.params?.height + if (typeof explicitWidth === 'number' && Number.isInteger(explicitWidth) && explicitWidth > 0 + && typeof explicitHeight === 'number' && Number.isInteger(explicitHeight) && explicitHeight > 0) { + return { width: explicitWidth, height: explicitHeight } + } + const size = typeof input.params?.size === 'string' ? /^(\d+)x(\d+)$/.exec(input.params.size) : null + if (size) return { width: Number(size[1]), height: Number(size[2]) } + if (input.sourceWidth && input.sourceHeight) { + const scale = Math.min(1, 1280 / Math.max(input.sourceWidth, input.sourceHeight)) + return { + width: Math.max(16, Math.round(input.sourceWidth * scale / 16) * 16), + height: Math.max(16, Math.round(input.sourceHeight * scale / 16) * 16), + } + } + return { width: 1152, height: 768 } +} + +function requirePublicImageUrl(value: string) { + try { + const url = new URL(value) + if (url.protocol !== 'https:' || url.username || url.password) throw new Error('invalid') + return url.toString() + } catch { + throw errors.badRequest('Agnes image-to-video requires a publicly reachable HTTPS image URL') + } +} + +function parseVideo(value: unknown, operation: string) { + if (value && typeof value === 'object' && 'error' in value && value.error && typeof value.error === 'object' + && 'message' in value.error && typeof value.error.message === 'string') { + throw new AgnesProviderError(`Agnes video ${operation} failed: ${boundedErrorText(value.error.message)}`) + } + const parsed = videoResponseSchema.safeParse(value) + if (!parsed.success) throw new AgnesProviderError('Agnes returned a malformed video response') + return parsed.data +} + +function dimensionsFromSize(value: string | undefined) { + const match = /^(\d+)x(\d+)$/.exec(value ?? '') + return match ? { width: Number(match[1]), height: Number(match[2]) } : {} +} + +function videoOutputUrl(video: z.infer) { + return video.metadata?.url || video.url || undefined +} + +export class AgnesProvider implements TextProvider, ImageProvider, VideoProvider { + readonly name = 'agnes' + private readonly apiKey: string + private readonly pollIntervalMs: number + private readonly predictionDeadlineSeconds: number + private readonly sleep: (milliseconds: number) => Promise + + constructor( + private readonly fetchImpl: typeof fetch = fetch, + options: AgnesRuntimeOptions = {}, + ) { + this.apiKey = requireApiKey() + this.pollIntervalMs = options.pollIntervalMs ?? config.AGNES_VIDEO_POLL_INTERVAL_MS + this.predictionDeadlineSeconds = options.predictionDeadlineSeconds ?? config.AGNES_VIDEO_PREDICTION_DEADLINE_SECONDS + this.sleep = options.sleep ?? ((milliseconds) => delay(milliseconds)) + } + + private async requestJson(path: string, body: Record) { + let response: Response + try { + response = await this.fetchImpl(`${agnesApiBaseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(180_000), + }) + } catch (error) { + if (error && typeof error === 'object' && 'name' in error && (error.name === 'AbortError' || error.name === 'TimeoutError')) { + throw new AgnesProviderError('Agnes request timed out') + } + throw new AgnesProviderError('Unable to connect to Agnes') + } + const text = await response.text() + if (response.status === 429) throw new AgnesRateLimitError(retryAfterMilliseconds(response)) + if (!response.ok) throw new AgnesProviderError(`Agnes API ${response.status}: ${boundedErrorText(text)}`, response.status) + try { + return JSON.parse(text) as unknown + } catch { + throw new AgnesProviderError('Agnes returned invalid JSON') + } + } + + private async requestGetUrl(url: string) { + let response: Response + try { + response = await this.fetchImpl(url, { + method: 'GET', + headers: { Authorization: `Bearer ${this.apiKey}`, Accept: 'application/json' }, + signal: AbortSignal.timeout(180_000), + }) + } catch { + throw new AgnesProviderError('Unable to connect to Agnes') + } + const text = await response.text() + if (response.status === 429) throw new AgnesRateLimitError(retryAfterMilliseconds(response)) + if (!response.ok) throw new AgnesProviderError(`Agnes API ${response.status}: ${boundedErrorText(text)}`, response.status) + try { + return JSON.parse(text) as unknown + } catch { + throw new AgnesProviderError('Agnes returned invalid JSON') + } + } + + async generateScript(input: ScriptGenerationInput): Promise { + const response = await this.requestJson('/chat/completions', { + model: config.AGNES_TEXT_MODEL, + messages: [ + { + role: 'system', + content: [ + '你是专业的中文漫剧编剧。只输出 JSON,不要 Markdown 代码围栏或解释。', + '故事必须可拍摄、镜头动作明确、对白简洁,并保持角色动机连续。', + `输出必须符合此 JSON Schema:${JSON.stringify(scriptJsonSchema)}`, + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + synopsis: input.synopsis, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + existingScript: input.existingScript ?? null, + }), + }, + ], + response_format: { type: 'json_object' }, + temperature: 0.4, + max_tokens: 12_000, + }) + const document = scriptDocumentSchema.safeParse(parseJsonContent(messageContent(response), 'script')) + if (!document.success) throw new AgnesProviderError('Agnes returned a script that does not match the required schema') + return { + document: document.data, + plainText: scriptToPlainText(document.data), + model: config.AGNES_TEXT_MODEL, + usage: usageRows(response && typeof response === 'object' && 'usage' in response ? response.usage : null), + } + } + + async generateStoryboard(input: StoryboardGenerationInput): Promise { + const response = await this.requestJson('/chat/completions', { + model: config.AGNES_TEXT_MODEL, + messages: [ + { + role: 'system', + content: [ + '你是专业的中文漫剧分镜导演。只输出 JSON,不要 Markdown 代码围栏或解释。', + '把完整剧本拆成可独立生成画面和视频的镜头,画面描述明确人物、动作、构图、光线和环境。', + 'subtitleText 只保留当前镜头需要朗读的对白或旁白;无对白时输出空字符串。', + `输出必须符合此 JSON Schema:${JSON.stringify(storyboardJsonSchema)}`, + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + availableCharacters: input.characterNames, + availableScenes: input.sceneNames, + script: input.script, + }), + }, + ], + response_format: { type: 'json_object' }, + temperature: 0.4, + max_tokens: 12_000, + }) + const document = storyboardDocumentSchema.safeParse(parseJsonContent(messageContent(response), 'storyboard')) + if (!document.success) throw new AgnesProviderError('Agnes returned a storyboard that does not match the required schema') + return { + shots: document.data.shots, + model: config.AGNES_TEXT_MODEL, + usage: usageRows(response && typeof response === 'object' && 'usage' in response ? response.usage : null), + } + } + + async generateImage(input: ImageGenerationInput): Promise { + const references = input.referenceUrls.length > 0 + ? `\n保持以下项目参考媒体所代表的角色和场景连续性(参考资产 ID 已在工作流中保存):${input.referenceUrls.join(', ')}` + : '' + const prompt = [input.prompt, input.negativePrompt ? `Avoid: ${input.negativePrompt}` : '', references].filter(Boolean).join('\n') + const customParams = { ...(input.params ?? {}) } + ;['model', 'prompt', 'n', 'size', 'response_format'].forEach((key) => delete customParams[key]) + const response = await this.requestJson('/images/generations', { + ...customParams, + model: config.AGNES_IMAGE_MODEL, + prompt, + n: 1, + size: `${input.width}x${input.height}`, + }) + if (!response || typeof response !== 'object' || !('data' in response) || !Array.isArray(response.data)) { + throw new AgnesProviderError('Agnes returned an invalid image response') + } + const image = response.data[0] + if (!image || typeof image !== 'object') throw new AgnesProviderError('Agnes returned no image data') + if ('b64_json' in image && typeof image.b64_json === 'string' && image.b64_json.length > 0) { + return { + data: Buffer.from(image.b64_json, 'base64'), + mimeType: 'image/png', + model: config.AGNES_IMAGE_MODEL, + width: input.width, + height: input.height, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 120 }], + } + } + if ('url' in image && typeof image.url === 'string' && image.url.length > 0) { + return { + url: image.url, + sourceUrl: image.url, + mimeType: imageMimeType(image.url), + model: config.AGNES_IMAGE_MODEL, + width: input.width, + height: input.height, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 120 }], + } + } + throw new AgnesProviderError('Agnes returned no image data') + } + + async generateVideo(input: VideoGenerationInput): Promise { + const startedAt = Date.now() + const frameRate = normalizeFrameRate(input.params?.frame_rate) + const numFrames = normalizeFrameCount(input.durationSeconds, frameRate, input.params?.num_frames) + const dimensions = videoDimensions(input) + const customParams = { ...(input.params ?? {}) } + ;['model', 'prompt', 'image', 'mode', 'height', 'width', 'num_frames', 'frame_rate', 'extra_body', 'seconds', 'size', 'input_reference'].forEach((key) => delete customParams[key]) + const createBody = { + ...customParams, + model: config.AGNES_VIDEO_MODEL, + prompt: input.prompt, + image: requirePublicImageUrl(input.imageUrl), + mode: 'ti2vid', + width: dimensions.width, + height: dimensions.height, + num_frames: numFrames, + frame_rate: frameRate, + } + let initial: z.infer + while (true) { + try { + initial = parseVideo(await this.requestJson('/videos', createBody), 'creation') + break + } catch (error) { + if (!(error instanceof AgnesRateLimitError)) throw error + if (Date.now() - startedAt + error.retryAfterMs > this.predictionDeadlineSeconds * 1000) { + throw new AgnesProviderError(`Agnes video generation exceeded the ${this.predictionDeadlineSeconds}s deadline`) + } + await this.sleep(error.retryAfterMs) + } + } + const resultVideoId = initial.video_id || initial.task_id || initial.id! + const resultUrl = new URL('/agnesapi', agnesApiOrigin) + resultUrl.searchParams.set('video_id', resultVideoId) + const legacyTaskId = initial.task_id || initial.id + const legacyResultUrl = legacyTaskId + ? `${agnesApiBaseUrl}/videos/${encodeURIComponent(legacyTaskId)}` + : null + let current = initial + let useLegacyResult = false + while (current.status !== 'completed' || !videoOutputUrl(current)) { + if (current.status === 'failed') throw new AgnesProviderError(current.error?.message || 'Agnes video generation failed') + if (Date.now() - startedAt > this.predictionDeadlineSeconds * 1000) { + throw new AgnesProviderError( + `Agnes video ${resultVideoId} remained ${current.status} at ${current.progress ?? 0}% after the ${this.predictionDeadlineSeconds}s deadline`, + ) + } + await this.sleep(this.pollIntervalMs) + try { + const pollUrl = useLegacyResult && legacyResultUrl ? legacyResultUrl : resultUrl.toString() + useLegacyResult = legacyResultUrl ? !useLegacyResult : false + current = parseVideo(await this.requestGetUrl(pollUrl), 'result retrieval') + } catch (error) { + if (!(error instanceof AgnesRateLimitError)) throw error + if (Date.now() - startedAt + error.retryAfterMs > this.predictionDeadlineSeconds * 1000) { + throw new AgnesProviderError( + `Agnes video ${resultVideoId} remained ${current.status} at ${current.progress ?? 0}% after the ${this.predictionDeadlineSeconds}s deadline`, + ) + } + await this.sleep(error.retryAfterMs) + } + } + const actualSeconds = Number(current.seconds) || numFrames / frameRate + const size = dimensionsFromSize(current.size) + const outputUrl = videoOutputUrl(current) + if (!outputUrl) throw new AgnesProviderError('Agnes completed the video without an output URL') + return { + url: outputUrl, + mimeType: 'video/mp4', + model: config.AGNES_VIDEO_MODEL, + externalId: initial.task_id || initial.id || initial.video_id!, + ...size, + usage: [{ kind: 'VIDEO_SECOND', quantity: actualSeconds, credits: actualSeconds * 35 }], + } + } + + async generateLipsync(_input: LipsyncGenerationInput): Promise { + throw errors.badRequest('Agnes does not provide lip-sync generation') + } + + async generateSpeech(_input: SpeechGenerationInput): Promise { + throw errors.badRequest('Agnes does not provide speech generation') + } +} diff --git a/server/src/providers/index.ts b/server/src/providers/index.ts new file mode 100644 index 0000000..07bacd1 --- /dev/null +++ b/server/src/providers/index.ts @@ -0,0 +1,34 @@ +import { AgnesProvider } from './agnes.js' +import { GenericOpenAICompatibleProvider } from './openai-compatible.js' +import { OpenAIProvider } from './openai.js' +import { ReplicateProvider } from './replicate.js' +import type { ImageProvider, SpeechProvider, TextProvider, VideoProvider } from './types.js' + +export function textProvider(name = 'openai'): TextProvider { + if (name === 'openai') return new OpenAIProvider() + if (name === 'agnes') return new AgnesProvider() + if (name === 'generic') return new GenericOpenAICompatibleProvider() + throw new Error(`Unknown text provider: ${name}`) +} + +export function imageProvider(name = 'openai'): ImageProvider { + if (name === 'openai') return new OpenAIProvider() + if (name === 'replicate') return new ReplicateProvider() + if (name === 'agnes') return new AgnesProvider() + if (name === 'generic') return new GenericOpenAICompatibleProvider() + throw new Error(`Unknown image provider: ${name}`) +} + +export function speechProvider(name = 'openai'): SpeechProvider { + if (name === 'openai') return new OpenAIProvider() + throw new Error(`Unknown speech provider: ${name}`) +} + +export function videoProvider(name = 'replicate'): VideoProvider { + if (name === 'replicate') return new ReplicateProvider() + if (name === 'agnes') return new AgnesProvider() + if (name === 'generic') return new GenericOpenAICompatibleProvider() + throw new Error(`Unknown video provider: ${name}`) +} + +export type * from './types.js' diff --git a/server/src/providers/openai-compatible.contract.test.ts b/server/src/providers/openai-compatible.contract.test.ts new file mode 100644 index 0000000..f683271 --- /dev/null +++ b/server/src/providers/openai-compatible.contract.test.ts @@ -0,0 +1,184 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { config } from '../config.js' +import { GenericOpenAICompatibleProvider } from './openai-compatible.js' + +const originalConfiguration = { + textApiKey: config.GENERIC_TEXT_API_KEY, + textBaseUrl: config.GENERIC_TEXT_BASE_URL, + textModel: config.GENERIC_TEXT_MODEL, + imageApiKey: config.GENERIC_IMAGE_API_KEY, + imageBaseUrl: config.GENERIC_IMAGE_BASE_URL, + imageModel: config.GENERIC_IMAGE_MODEL, + videoApiKey: config.GENERIC_VIDEO_API_KEY, + videoBaseUrl: config.GENERIC_VIDEO_BASE_URL, + videoModel: config.GENERIC_VIDEO_MODEL, +} + +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } }) +} + +beforeAll(() => { + config.GENERIC_TEXT_API_KEY = 'generic-text-secret' + config.GENERIC_TEXT_BASE_URL = 'https://text.vendor.test/v1' + config.GENERIC_TEXT_MODEL = 'vendor-text-model' + config.GENERIC_IMAGE_API_KEY = 'generic-image-secret' + config.GENERIC_IMAGE_BASE_URL = 'https://image.vendor.test/api/v1/' + config.GENERIC_IMAGE_MODEL = 'vendor-image-model' + config.GENERIC_VIDEO_API_KEY = 'generic-video-secret' + config.GENERIC_VIDEO_BASE_URL = 'https://video.vendor.test/v1' + config.GENERIC_VIDEO_MODEL = 'vendor-video-model' +}) + +afterAll(() => { + config.GENERIC_TEXT_API_KEY = originalConfiguration.textApiKey + config.GENERIC_TEXT_BASE_URL = originalConfiguration.textBaseUrl + config.GENERIC_TEXT_MODEL = originalConfiguration.textModel + config.GENERIC_IMAGE_API_KEY = originalConfiguration.imageApiKey + config.GENERIC_IMAGE_BASE_URL = originalConfiguration.imageBaseUrl + config.GENERIC_IMAGE_MODEL = originalConfiguration.imageModel + config.GENERIC_VIDEO_API_KEY = originalConfiguration.videoApiKey + config.GENERIC_VIDEO_BASE_URL = originalConfiguration.videoBaseUrl + config.GENERIC_VIDEO_MODEL = originalConfiguration.videoModel +}) + +describe('generic OpenAI-compatible provider contract', () => { + it('uses the independently configured text endpoint and validates script JSON', async () => { + const document = { + title: '通用接口剧本', + logline: '一封信改变调查方向。', + estimatedDurationSeconds: 30, + scenes: [{ + number: 1, + location: '档案室', + interiorExterior: '内', + time: '夜', + summary: '主角找到线索。', + beats: [{ type: 'action', text: '灯光闪烁。', character: null, parenthetical: null }], + }], + } + const fetchMock = vi.fn(async (request, init) => { + expect(String(request)).toBe('https://text.vendor.test/v1/chat/completions') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer generic-text-secret') + const body = JSON.parse(init?.body as string) + expect(body).toMatchObject({ model: 'vendor-text-model', response_format: { type: 'json_object' } }) + return jsonResponse({ + choices: [{ message: { content: JSON.stringify(document) } }], + usage: { prompt_tokens: 80, completion_tokens: 20 }, + }) + }) + + const result = await new GenericOpenAICompatibleProvider(fetchMock).generateScript({ + instruction: '生成悬疑短剧', + projectName: '夜间档案', + genre: '悬疑', + visualBible: {}, + episodeTitle: '第一集', + synopsis: '寻找旧案线索', + targetDurationSeconds: 30, + safetyIdentifier: 'user-1', + }) + + expect(result).toMatchObject({ model: 'vendor-text-model', document: { title: '通用接口剧本' } }) + expect(result.usage).toEqual([ + { kind: 'TEXT_INPUT_TOKEN', quantity: 80, credits: 0.08 }, + { kind: 'TEXT_OUTPUT_TOKEN', quantity: 20, credits: 0.04 }, + ]) + }) + + it('uses multipart image edits when continuity references are supplied', async () => { + const fetchMock = vi.fn(async (request, init) => { + expect(String(request)).toBe('https://image.vendor.test/api/v1/images/edits') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer generic-image-secret') + expect(new Headers(init?.headers).has('content-type')).toBe(false) + expect(init?.body).toBeInstanceOf(FormData) + const form = init?.body as FormData + expect(form.get('model')).toBe('vendor-image-model') + expect(form.get('size')).toBe('1024x1024') + expect(form.getAll('image')).toHaveLength(1) + return jsonResponse({ data: [{ url: 'https://cdn.vendor.test/generated/frame.webp' }] }) + }) + + const result = await new GenericOpenAICompatibleProvider(fetchMock).generateImage({ + prompt: '角色转身看向镜头', + width: 1024, + height: 1024, + references: [{ name: 'character.png', mimeType: 'image/png', data: Buffer.from('reference-image') }], + referenceUrls: ['https://media.example/reference.png'], + safetyIdentifier: 'user-1', + }) + + expect(result).toMatchObject({ + url: 'https://cdn.vendor.test/generated/frame.webp', + sourceUrl: 'https://cdn.vendor.test/generated/frame.webp', + mimeType: 'image/webp', + model: 'vendor-image-model', + }) + }) + + it('creates, polls, and downloads a generic asynchronous video job', async () => { + const videoData = Buffer.concat([Buffer.from([0, 0, 0, 24]), Buffer.from('ftypisom'), Buffer.from('generic-contract-mp4')]) + let pollCount = 0 + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + if (url === 'https://video.vendor.test/v1/videos' && init?.method === 'POST') { + expect(new Headers(init.headers).get('authorization')).toBe('Bearer generic-video-secret') + const form = init.body as FormData + expect(form.get('model')).toBe('vendor-video-model') + expect(form.get('prompt')).toBe('镜头向人物推进') + expect(form.get('seconds')).toBe('5') + expect(form.get('size')).toBe('1280x720') + expect(form.get('input_reference')).toBeInstanceOf(Blob) + return jsonResponse({ id: 'video-contract', status: 'queued', progress: 0 }) + } + if (url === 'https://video.vendor.test/v1/videos/video-contract' && init?.method === 'GET') { + pollCount += 1 + return jsonResponse({ id: 'video-contract', status: 'completed', seconds: '5', size: '1280x720', model: 'vendor-video-output' }) + } + if (url === 'https://video.vendor.test/v1/videos/video-contract/content' && init?.method === 'GET') { + expect(new Headers(init.headers).get('authorization')).toBe('Bearer generic-video-secret') + return new Response(videoData, { status: 200, headers: { 'content-type': 'video/mp4' } }) + } + return new Response('unexpected request', { status: 500 }) + }) + + const result = await new GenericOpenAICompatibleProvider(fetchMock, { + pollIntervalMs: 1, + sleep: async () => undefined, + }).generateVideo({ + prompt: '镜头向人物推进', + imageUrl: 'https://media.example/source.png', + imageData: Buffer.from('source-image'), + imageMimeType: 'image/png', + sourceWidth: 1280, + sourceHeight: 720, + durationSeconds: 5, + }) + + expect(pollCount).toBe(1) + expect(result).toMatchObject({ + data: videoData, + mimeType: 'video/mp4', + model: 'vendor-video-output', + externalId: 'video-contract', + width: 1280, + height: 720, + usage: [{ kind: 'VIDEO_SECOND', quantity: 5, credits: 175 }], + }) + }) + + it('redacts independently configured credentials from provider failures', async () => { + const fetchMock = vi.fn(async () => new Response(`failure generic-image-secret ${'x'.repeat(2000)}`, { status: 500 })) + const request = new GenericOpenAICompatibleProvider(fetchMock).generateImage({ + prompt: 'test', + width: 1024, + height: 1024, + references: [], + referenceUrls: [], + safetyIdentifier: 'user-1', + }) + + await expect(request).rejects.not.toThrow('generic-image-secret') + await expect(request).rejects.toThrow(/Generic provider API 500/) + }) +}) diff --git a/server/src/providers/openai-compatible.ts b/server/src/providers/openai-compatible.ts new file mode 100644 index 0000000..3307c54 --- /dev/null +++ b/server/src/providers/openai-compatible.ts @@ -0,0 +1,432 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import { + scriptDocumentSchema, + scriptJsonSchema, + scriptToPlainText, + storyboardDocumentSchema, + storyboardJsonSchema, +} from './openai.js' +import type { + BinaryGenerationResult, + ImageGenerationInput, + ImageProvider, + LipsyncGenerationInput, + ScriptGenerationInput, + ScriptGenerationResult, + StoryboardGenerationInput, + StoryboardGenerationResult, + TextProvider, + VideoGenerationInput, + VideoProvider, +} from './types.js' + +interface GenericRuntimeOptions { + pollIntervalMs?: number + predictionDeadlineSeconds?: number + sleep?: (milliseconds: number) => Promise +} + +interface CapabilityConfiguration { + apiKey: string + baseUrl: string + model: string +} + +class GenericProviderError extends Error { + constructor(message: string, readonly status?: number) { + super(message) + this.name = 'GenericProviderError' + } +} + +const videoResponseSchema = z.object({ + id: z.string().trim().min(1).max(2048), + status: z.enum(['queued', 'in_progress', 'processing', 'completed', 'succeeded', 'failed', 'canceled']), + progress: z.number().finite().min(0).max(100).optional(), + seconds: z.union([z.string(), z.number()]).optional(), + size: z.string().trim().max(40).optional(), + model: z.string().trim().min(1).max(255).optional(), + url: z.string().url().optional(), + output_url: z.string().url().optional(), + metadata: z.object({ url: z.string().url().optional() }).passthrough().nullable().optional(), + data: z.array(z.object({ url: z.string().url().optional() }).passthrough()).optional(), + output: z.array(z.object({ url: z.string().url().optional() }).passthrough()).optional(), + error: z.union([ + z.string().max(10_000), + z.object({ message: z.string().max(10_000) }).passthrough(), + ]).nullable().optional(), +}).passthrough() + +function capabilityConfiguration(kind: 'text' | 'image' | 'video'): CapabilityConfiguration { + const values = kind === 'text' + ? [config.GENERIC_TEXT_API_KEY, config.GENERIC_TEXT_BASE_URL, config.GENERIC_TEXT_MODEL] + : kind === 'image' + ? [config.GENERIC_IMAGE_API_KEY, config.GENERIC_IMAGE_BASE_URL, config.GENERIC_IMAGE_MODEL] + : [config.GENERIC_VIDEO_API_KEY, config.GENERIC_VIDEO_BASE_URL, config.GENERIC_VIDEO_MODEL] + const [apiKey, baseUrl, model] = values + if (!apiKey || !baseUrl || !model) { + throw errors.badRequest(`GENERIC_${kind.toUpperCase()}_API_KEY, BASE_URL, and MODEL must be configured together`) + } + return { apiKey, baseUrl, model } +} + +function endpoint(baseUrl: string, path: string) { + const base = new URL(baseUrl) + if (base.username || base.password || base.search || base.hash) { + throw errors.badRequest('Generic provider BASE_URL must not contain credentials, query parameters, or a fragment') + } + return `${base.toString().replace(/\/$/, '')}/${path.replace(/^\//, '')}` +} + +function boundedErrorText(value: string) { + const secrets = [config.GENERIC_TEXT_API_KEY, config.GENERIC_IMAGE_API_KEY, config.GENERIC_VIDEO_API_KEY] + .filter((secret): secret is string => Boolean(secret)) + return secrets.reduce((text, secret) => text.replaceAll(secret, '[redacted]'), value).slice(0, 1000) +} + +function imageMimeType(url: string) { + try { + const pathname = new URL(url).pathname.toLowerCase() + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg' + if (pathname.endsWith('.webp')) return 'image/webp' + } catch { + // The shared media downloader performs authoritative URL validation. + } + return 'image/png' +} + +function messageContent(response: unknown) { + if (!response || typeof response !== 'object' || !('choices' in response) || !Array.isArray(response.choices)) { + throw new GenericProviderError('Generic text provider returned an invalid chat response') + } + const first = response.choices[0] + const message = first && typeof first === 'object' && 'message' in first ? first.message : null + if (!message || typeof message !== 'object' || !('content' in message)) { + throw new GenericProviderError('Generic text provider returned an empty chat response') + } + if (typeof message.content === 'string') return message.content + if (Array.isArray(message.content)) { + return message.content + .map((part: unknown) => part && typeof part === 'object' && 'text' in part && typeof part.text === 'string' ? part.text : '') + .join('') + } + throw new GenericProviderError('Generic text provider returned an empty chat response') +} + +function parseJsonContent(value: string, label: string) { + const normalized = value.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/i, '') + try { + return JSON.parse(normalized) as unknown + } catch { + throw new GenericProviderError(`Generic text provider returned malformed JSON for ${label}`) + } +} + +function usageRows(response: unknown) { + const usage = response && typeof response === 'object' && 'usage' in response ? response.usage : null + if (!usage || typeof usage !== 'object') return [] + const input = 'prompt_tokens' in usage && typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : 0 + const output = 'completion_tokens' in usage && typeof usage.completion_tokens === 'number' ? usage.completion_tokens : 0 + return [ + ...(input > 0 ? [{ kind: 'TEXT_INPUT_TOKEN' as const, quantity: input, credits: input / 1000 }] : []), + ...(output > 0 ? [{ kind: 'TEXT_OUTPUT_TOKEN' as const, quantity: output, credits: output / 500 }] : []), + ] +} + +function appendFormValue(form: FormData, key: string, value: unknown) { + if (value === undefined || value === null) return + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + form.append(key, String(value)) + return + } + form.append(key, JSON.stringify(value)) +} + +function outputVideoUrl(video: z.infer) { + return video.url + || video.output_url + || video.metadata?.url + || video.data?.find((item) => item.url)?.url + || video.output?.find((item) => item.url)?.url +} + +function videoDimensions(input: VideoGenerationInput) { + const requestedSize = typeof input.params?.size === 'string' && /^\d+x\d+$/.test(input.params.size) + ? input.params.size + : null + if (requestedSize) return requestedSize + if (input.sourceWidth && input.sourceHeight) return `${input.sourceWidth}x${input.sourceHeight}` + return '1280x720' +} + +function dimensionsFromSize(value: string | undefined) { + const match = /^(\d+)x(\d+)$/.exec(value ?? '') + return match ? { width: Number(match[1]), height: Number(match[2]) } : {} +} + +function parseVideo(value: unknown) { + const parsed = videoResponseSchema.safeParse(value) + if (!parsed.success) throw new GenericProviderError('Generic video provider returned a malformed response') + return parsed.data +} + +export class GenericOpenAICompatibleProvider implements TextProvider, ImageProvider, VideoProvider { + readonly name = 'generic' + private readonly pollIntervalMs: number + private readonly predictionDeadlineSeconds: number + private readonly sleep: (milliseconds: number) => Promise + + constructor( + private readonly fetchImpl: typeof fetch = fetch, + options: GenericRuntimeOptions = {}, + ) { + this.pollIntervalMs = options.pollIntervalMs ?? config.GENERIC_VIDEO_POLL_INTERVAL_MS + this.predictionDeadlineSeconds = options.predictionDeadlineSeconds ?? config.GENERIC_VIDEO_PREDICTION_DEADLINE_SECONDS + this.sleep = options.sleep ?? ((milliseconds) => delay(milliseconds)) + } + + private async requestJson(configuration: CapabilityConfiguration, path: string, init: RequestInit) { + let response: Response + try { + response = await this.fetchImpl(endpoint(configuration.baseUrl, path), { + ...init, + headers: { + Authorization: `Bearer ${configuration.apiKey}`, + Accept: 'application/json', + ...init.headers, + }, + signal: AbortSignal.timeout(180_000), + }) + } catch (error) { + const name = error && typeof error === 'object' && 'name' in error ? error.name : null + if (name === 'AbortError' || name === 'TimeoutError') throw new GenericProviderError('Generic provider request timed out') + throw new GenericProviderError('Unable to connect to generic provider') + } + const text = await response.text() + if (!response.ok) throw new GenericProviderError(`Generic provider API ${response.status}: ${boundedErrorText(text)}`, response.status) + try { + return JSON.parse(text) as unknown + } catch { + throw new GenericProviderError('Generic provider returned invalid JSON') + } + } + + private async postJson(configuration: CapabilityConfiguration, path: string, body: Record) { + return this.requestJson(configuration, path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + async generateScript(input: ScriptGenerationInput): Promise { + const configuration = capabilityConfiguration('text') + const response = await this.postJson(configuration, '/chat/completions', { + model: configuration.model, + messages: [ + { + role: 'system', + content: [ + '你是专业的中文漫剧编剧。只输出 JSON,不要 Markdown 代码围栏或解释。', + '故事必须可拍摄、镜头动作明确、对白简洁,并保持角色动机连续。', + `输出必须符合此 JSON Schema:${JSON.stringify(scriptJsonSchema)}`, + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + synopsis: input.synopsis, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + existingScript: input.existingScript ?? null, + }), + }, + ], + response_format: { type: 'json_object' }, + temperature: 0.4, + max_tokens: 12_000, + }) + const document = scriptDocumentSchema.safeParse(parseJsonContent(messageContent(response), 'script')) + if (!document.success) throw new GenericProviderError('Generic text provider returned a script that does not match the required schema') + return { + document: document.data, + plainText: scriptToPlainText(document.data), + model: configuration.model, + usage: usageRows(response), + } + } + + async generateStoryboard(input: StoryboardGenerationInput): Promise { + const configuration = capabilityConfiguration('text') + const response = await this.postJson(configuration, '/chat/completions', { + model: configuration.model, + messages: [ + { + role: 'system', + content: [ + '你是专业的中文漫剧分镜导演。只输出 JSON,不要 Markdown 代码围栏或解释。', + '把完整剧本拆成可独立生成画面和视频的镜头,明确人物、动作、构图、光线和环境。', + 'subtitleText 只保留当前镜头需要朗读的对白或旁白;无对白时输出空字符串。', + `输出必须符合此 JSON Schema:${JSON.stringify(storyboardJsonSchema)}`, + ].join('\n'), + }, + { + role: 'user', + content: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + availableCharacters: input.characterNames, + availableScenes: input.sceneNames, + script: input.script, + }), + }, + ], + response_format: { type: 'json_object' }, + temperature: 0.4, + max_tokens: 12_000, + }) + const document = storyboardDocumentSchema.safeParse(parseJsonContent(messageContent(response), 'storyboard')) + if (!document.success) throw new GenericProviderError('Generic text provider returned a storyboard that does not match the required schema') + return { shots: document.data.shots, model: configuration.model, usage: usageRows(response) } + } + + async generateImage(input: ImageGenerationInput): Promise { + const configuration = capabilityConfiguration('image') + const references = input.referenceUrls.length > 0 + ? `\nContinuity reference URLs: ${input.referenceUrls.join(', ')}` + : '' + const prompt = [input.prompt, input.negativePrompt ? `Avoid: ${input.negativePrompt}` : '', references].filter(Boolean).join('\n') + const customParams = { ...(input.params ?? {}) } + ;['model', 'prompt', 'n', 'size', 'response_format', 'image'].forEach((key) => delete customParams[key]) + const response = input.references.length > 0 + ? await (() => { + const form = new FormData() + form.append('model', configuration.model) + form.append('prompt', prompt) + form.append('n', '1') + form.append('size', `${input.width}x${input.height}`) + input.references.forEach((reference) => { + form.append('image', new Blob([reference.data], { type: reference.mimeType }), reference.name) + }) + Object.entries(customParams).forEach(([key, value]) => appendFormValue(form, key, value)) + return this.requestJson(configuration, '/images/edits', { method: 'POST', body: form }) + })() + : await this.postJson(configuration, '/images/generations', { + ...customParams, + model: configuration.model, + prompt, + n: 1, + size: `${input.width}x${input.height}`, + }) + if (!response || typeof response !== 'object' || !('data' in response) || !Array.isArray(response.data)) { + throw new GenericProviderError('Generic image provider returned an invalid response') + } + const image = response.data[0] + if (!image || typeof image !== 'object') throw new GenericProviderError('Generic image provider returned no image data') + if ('b64_json' in image && typeof image.b64_json === 'string' && image.b64_json.length > 0) { + return { + data: Buffer.from(image.b64_json, 'base64'), + mimeType: 'image/png', + model: configuration.model, + width: input.width, + height: input.height, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 120 }], + } + } + if ('url' in image && typeof image.url === 'string' && image.url.length > 0) { + return { + url: image.url, + sourceUrl: image.url, + mimeType: imageMimeType(image.url), + model: configuration.model, + width: input.width, + height: input.height, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 120 }], + } + } + throw new GenericProviderError('Generic image provider returned no image data') + } + + async generateVideo(input: VideoGenerationInput): Promise { + const configuration = capabilityConfiguration('video') + const durationSeconds = Math.max(1, Math.round(input.durationSeconds)) + const size = videoDimensions(input) + const form = new FormData() + form.append('model', configuration.model) + form.append('prompt', input.prompt) + form.append('seconds', String(durationSeconds)) + form.append('size', size) + if (input.imageData) { + form.append('input_reference', new Blob([input.imageData], { type: input.imageMimeType ?? 'image/png' }), 'reference-image') + } else { + form.append('input_reference', input.imageUrl) + } + Object.entries(input.params ?? {}) + .filter(([key]) => !['model', 'prompt', 'seconds', 'size', 'input_reference'].includes(key)) + .forEach(([key, value]) => appendFormValue(form, key, value)) + + let current = parseVideo(await this.requestJson(configuration, '/videos', { method: 'POST', body: form })) + const startedAt = Date.now() + while (!['completed', 'succeeded'].includes(current.status)) { + if (current.status === 'failed' || current.status === 'canceled') { + const reason = typeof current.error === 'string' ? current.error : current.error?.message + throw new GenericProviderError(reason || `Generic video generation ${current.status}`) + } + if (Date.now() - startedAt > this.predictionDeadlineSeconds * 1000) { + throw new GenericProviderError(`Generic video generation exceeded the ${this.predictionDeadlineSeconds}s deadline`) + } + await this.sleep(this.pollIntervalMs) + current = parseVideo(await this.requestJson(configuration, `/videos/${encodeURIComponent(current.id)}`, { method: 'GET' })) + } + + const outputUrl = outputVideoUrl(current) + const media = outputUrl + ? { url: outputUrl } + : { data: await this.downloadVideoContent(configuration, current.id) } + const actualSeconds = Number(current.seconds) || durationSeconds + return { + ...media, + mimeType: 'video/mp4', + model: current.model ?? configuration.model, + externalId: current.id, + ...dimensionsFromSize(current.size ?? size), + usage: [{ kind: 'VIDEO_SECOND', quantity: actualSeconds, credits: actualSeconds * 35 }], + } + } + + private async downloadVideoContent(configuration: CapabilityConfiguration, videoId: string) { + let response: Response + try { + response = await this.fetchImpl(endpoint(configuration.baseUrl, `/videos/${encodeURIComponent(videoId)}/content`), { + method: 'GET', + headers: { Authorization: `Bearer ${configuration.apiKey}`, Accept: 'video/mp4' }, + signal: AbortSignal.timeout(180_000), + }) + } catch { + throw new GenericProviderError('Unable to download generic provider video content') + } + if (!response.ok) { + throw new GenericProviderError(`Generic provider video content ${response.status}: ${boundedErrorText(await response.text())}`, response.status) + } + const data = Buffer.from(await response.arrayBuffer()) + if (data.length < 12 || data.subarray(4, 8).toString('ascii') !== 'ftyp') { + throw new GenericProviderError('Generic provider returned invalid MP4 video content') + } + return data + } + + async generateLipsync(_input: LipsyncGenerationInput): Promise { + throw errors.badRequest('Generic OpenAI-compatible provider does not define a lip-sync contract') + } +} diff --git a/server/src/providers/openai.contract.test.ts b/server/src/providers/openai.contract.test.ts new file mode 100644 index 0000000..96c1297 --- /dev/null +++ b/server/src/providers/openai.contract.test.ts @@ -0,0 +1,337 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { config } from '../config.js' +import { OpenAIProvider, openAIImageDimensions } from './openai.js' +import type { ScriptDocument } from './types.js' + +const originalConfiguration = { + apiKey: config.OPENAI_API_KEY, + textModel: config.OPENAI_TEXT_MODEL, + imageModel: config.OPENAI_IMAGE_MODEL, + ttsModel: config.OPENAI_TTS_MODEL, + ttsVoice: config.OPENAI_TTS_VOICE, +} + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', 'x-request-id': 'contract-request' }, + }) +} + +function responsesApiBody(id: string, text: string, usage: { input_tokens: number; output_tokens: number }) { + return { + id, + object: 'response', + status: 'completed', + output: [{ + id: `message-${id}`, + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text, annotations: [] }], + }], + usage: { ...usage, total_tokens: usage.input_tokens + usage.output_tokens }, + } +} + +function requestBody(init?: RequestInit) { + expect(typeof init?.body).toBe('string') + return JSON.parse(init?.body as string) as Record +} + +function expectOpenAIRequest(input: Parameters[0], init: Parameters[1], pathname: string) { + expect(new URL(String(input)).pathname).toBe(pathname) + expect(init?.method).toBe('POST') + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBe('Bearer contract-openai-key') + return requestBody(init) +} + +beforeAll(() => { + config.OPENAI_API_KEY = 'contract-openai-key' + config.OPENAI_TEXT_MODEL = 'contract-text-model' + config.OPENAI_IMAGE_MODEL = 'contract-image-model' + config.OPENAI_TTS_MODEL = 'contract-tts-model' + config.OPENAI_TTS_VOICE = 'contract-voice' +}) + +afterAll(() => { + config.OPENAI_API_KEY = originalConfiguration.apiKey + config.OPENAI_TEXT_MODEL = originalConfiguration.textModel + config.OPENAI_IMAGE_MODEL = originalConfiguration.imageModel + config.OPENAI_TTS_MODEL = originalConfiguration.ttsModel + config.OPENAI_TTS_VOICE = originalConfiguration.ttsVoice +}) + +describe('OpenAI provider contract', () => { + it('normalizes GPT Image 2 dimensions to documented limits while preserving aspect ratio', () => { + expect(openAIImageDimensions('gpt-image-2', 1920, 1080)).toEqual({ width: 1920, height: 1088, size: '1920x1088' }) + expect(openAIImageDimensions('gpt-image-2-2026-04-21', 2160, 3840)).toEqual({ width: 2160, height: 3840, size: '2160x3840' }) + expect(openAIImageDimensions('gpt-image-1.5', 1920, 1080)).toEqual({ width: 1536, height: 1024, size: '1536x1024' }) + }) + + it('sends a strict Responses API script request and maps structured output and usage', async () => { + const document: ScriptDocument = { + title: '雾港来信', + logline: '记者收到一封来自未来的信。', + estimatedDurationSeconds: 90, + scenes: [{ + number: 1, + location: '旧港仓库', + interiorExterior: '内', + time: '夜', + summary: '林遥找到录音机。', + beats: [ + { type: 'action', text: '卷帘门缓慢升起。', character: null, parenthetical: null }, + { type: 'dialogue', text: '这不是今天录的。', character: '林遥', parenthetical: '压低声音' }, + ], + }], + } + const fetchMock = vi.fn(async (input, init) => { + const body = expectOpenAIRequest(input, init, '/v1/responses') + expect(body).toMatchObject({ + model: 'contract-text-model', + store: false, + safety_identifier: 'user-contract-1', + text: { format: { type: 'json_schema', name: 'comic_drama_script', strict: true } }, + }) + const prompt = JSON.parse(body.input as string) as Record + expect(prompt).toMatchObject({ + task: '强化悬疑钩子', + project: '雾港档案', + episodeTitle: '第一封信', + existingScript: null, + }) + return jsonResponse(responsesApiBody('resp-script', JSON.stringify(document), { input_tokens: 1200, output_tokens: 450 })) + }) + + const result = await new OpenAIProvider(fetchMock).generateScript({ + instruction: '强化悬疑钩子', + projectName: '雾港档案', + genre: '都市悬疑', + visualBible: { palette: 'cold' }, + episodeTitle: '第一封信', + synopsis: '记者追查未来录音。', + targetDurationSeconds: 90, + safetyIdentifier: 'user-contract-1', + }) + + expect(result.document).toEqual(document) + expect(result.plainText).toContain('场 1 · 内 · 旧港仓库 · 夜') + expect(result.plainText).toContain('林遥(压低声音):这不是今天录的。') + expect(result.usage).toEqual([ + { kind: 'TEXT_INPUT_TOKEN', quantity: 1200, credits: 1.2 }, + { kind: 'TEXT_OUTPUT_TOKEN', quantity: 450, credits: 0.9 }, + ]) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + it('sends storyboard assets and maps the strict shot document', async () => { + const shots = [{ + sceneNumber: 1, + title: '仓库门开启', + description: '林遥站在雨夜仓库门前,冷色逆光。', + shotType: '中景', + cameraMotion: '缓慢推进', + durationMs: 3200, + characterNames: ['林遥'], + sceneName: '旧港仓库', + subtitleText: '门后有人。', + }] + const fetchMock = vi.fn(async (input, init) => { + const body = expectOpenAIRequest(input, init, '/v1/responses') + expect(body).toMatchObject({ + model: 'contract-text-model', + safety_identifier: 'user-contract-2', + text: { format: { name: 'comic_drama_storyboard', strict: true } }, + }) + const prompt = JSON.parse(body.input as string) as Record + expect(prompt.availableCharacters).toEqual(['林遥']) + expect(prompt.availableScenes).toEqual(['旧港仓库']) + expect(prompt.script).toBe('场 1,林遥进入仓库。') + return jsonResponse(responsesApiBody('resp-storyboard', JSON.stringify({ shots }), { input_tokens: 800, output_tokens: 300 })) + }) + + const result = await new OpenAIProvider(fetchMock).generateStoryboard({ + projectName: '雾港档案', + genre: '都市悬疑', + visualBible: {}, + episodeTitle: '第一封信', + targetDurationSeconds: 90, + script: '场 1,林遥进入仓库。', + instruction: '拆成电影感镜头', + characterNames: ['林遥'], + sceneNames: ['旧港仓库'], + safetyIdentifier: 'user-contract-2', + }) + + expect(result.shots).toEqual(shots) + expect(result.usage).toHaveLength(2) + }) + + it('generates an image with the requested aspect mapping and binary response', async () => { + const png = Buffer.from('contract-png') + const fetchMock = vi.fn(async (input, init) => { + const body = expectOpenAIRequest(input, init, '/v1/images/generations') + expect(body).toMatchObject({ + model: 'contract-image-model', + prompt: '雨夜仓库\nAvoid: watermark', + size: '1536x1024', + quality: 'high', + output_format: 'png', + user: 'user-contract-3', + }) + return jsonResponse({ created: 1, data: [{ b64_json: png.toString('base64') }] }) + }) + + const result = await new OpenAIProvider(fetchMock).generateImage({ + prompt: '雨夜仓库', + negativePrompt: 'watermark', + width: 1920, + height: 1080, + references: [], + referenceUrls: [], + safetyIdentifier: 'user-contract-3', + }) + + expect(result.data).toEqual(png) + expect(result).toMatchObject({ mimeType: 'image/png', model: 'contract-image-model' }) + }) + + it('uses multipart image editing when character references are supplied', async () => { + const fetchMock = vi.fn(async (input, init) => { + expect(new URL(String(input)).pathname).toBe('/v1/images/edits') + expect(init?.method).toBe('POST') + expect(init?.body).toBeInstanceOf(FormData) + const form = init?.body as FormData + expect(form.get('model')).toBe('contract-image-model') + expect(form.get('input_fidelity')).toBe('high') + expect(form.get('size')).toBe('1024x1536') + expect(String(form.get('prompt'))).toContain('73% fidelity') + const files = form.getAll('image[]') + expect(files).toHaveLength(1) + expect(files[0]).toBeInstanceOf(File) + return jsonResponse({ created: 1, data: [{ url: 'https://provider.example/character.png' }] }) + }) + + const result = await new OpenAIProvider(fetchMock).generateImage({ + prompt: '林遥回头看向镜头', + width: 1080, + height: 1920, + references: [{ name: 'linyao.png', mimeType: 'image/png', data: Buffer.from('reference') }], + referenceUrls: ['https://media.example/linyao.png'], + params: { consistencyStrength: 73 }, + safetyIdentifier: 'user-contract-4', + }) + + expect(result.url).toBe('https://provider.example/character.png') + }) + + it('omits the unsupported input_fidelity field for GPT Image 2 reference edits', async () => { + config.OPENAI_IMAGE_MODEL = 'gpt-image-2' + try { + const fetchMock = vi.fn(async (input, init) => { + expect(new URL(String(input)).pathname).toBe('/v1/images/edits') + const form = init?.body as FormData + expect(form.get('model')).toBe('gpt-image-2') + expect(form.get('input_fidelity')).toBeNull() + expect(form.get('size')).toBe('1088x1920') + return jsonResponse({ created: 1, data: [{ b64_json: Buffer.from('gpt-image-2-result').toString('base64') }] }) + }) + + const result = await new OpenAIProvider(fetchMock).generateImage({ + prompt: '保持人物一致性', + width: 1080, + height: 1920, + references: [{ name: 'reference.png', mimeType: 'image/png', data: Buffer.from('reference') }], + referenceUrls: [], + safetyIdentifier: 'user-contract-gpt-image-2', + }) + + expect(result).toMatchObject({ width: 1088, height: 1920, mimeType: 'image/png' }) + } finally { + config.OPENAI_IMAGE_MODEL = 'contract-image-model' + } + }) + + it('rejects incomplete, refused, and schema-invalid structured responses', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ + id: 'resp-incomplete', + object: 'response', + status: 'incomplete', + incomplete_details: { reason: 'max_output_tokens' }, + output: [], + })) + .mockResolvedValueOnce(jsonResponse({ + id: 'resp-refusal', + object: 'response', + status: 'completed', + output: [{ + id: 'message-refusal', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'refusal', refusal: '该请求无法完成' }], + }], + })) + .mockResolvedValueOnce(jsonResponse(responsesApiBody('resp-invalid-schema', JSON.stringify({ title: '缺少场景' }), { input_tokens: 1, output_tokens: 1 }))) + const provider = new OpenAIProvider(fetchMock) + const input = { + instruction: '生成剧本', + projectName: '雾港档案', + genre: '都市悬疑', + visualBible: {}, + episodeTitle: '第一封信', + synopsis: '记者追查未来录音。', + targetDurationSeconds: 90, + safetyIdentifier: 'user-contract-terminal-states', + } + + await expect(provider.generateScript(input)).rejects.toThrow('max_output_tokens') + await expect(provider.generateScript(input)).rejects.toThrow('该请求无法完成') + await expect(provider.generateScript(input)).rejects.toThrow('does not match the required schema') + }) + + it('requests MP3 speech with voice controls and returns audio bytes', async () => { + const audio = Buffer.from('contract-mp3') + const fetchMock = vi.fn(async (input, init) => { + const body = expectOpenAIRequest(input, init, '/v1/audio/speech') + expect(body).toEqual({ + model: 'contract-tts-model', + voice: 'nova', + input: '门后有人。', + response_format: 'mp3', + instructions: '压低声音,保持紧张', + speed: 0.95, + }) + return new Response(audio, { status: 200, headers: { 'content-type': 'audio/mpeg' } }) + }) + + const result = await new OpenAIProvider(fetchMock).generateSpeech({ + text: '门后有人。', + voice: 'nova', + instructions: '压低声音,保持紧张', + speed: 0.95, + safetyIdentifier: 'user-contract-5', + }) + + expect(result.data).toEqual(audio) + expect(result.mimeType).toBe('audio/mpeg') + expect(result.usage[0]?.kind).toBe('AUDIO_SECOND') + }) + + it('propagates a provider error without exposing the API key', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ error: { message: 'invalid request', type: 'invalid_request_error' } }, 400)) + const provider = new OpenAIProvider(fetchMock) + + await expect(provider.generateSpeech({ + text: 'test', + safetyIdentifier: 'user-contract-error', + })).rejects.toThrow('invalid request') + await expect(provider.generateSpeech({ + text: 'test', + safetyIdentifier: 'user-contract-error', + })).rejects.not.toThrow('contract-openai-key') + }) +}) diff --git a/server/src/providers/openai.ts b/server/src/providers/openai.ts new file mode 100644 index 0000000..07f03c4 --- /dev/null +++ b/server/src/providers/openai.ts @@ -0,0 +1,381 @@ +import OpenAI, { toFile } from 'openai' +import { z } from 'zod' +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import type { + BinaryGenerationResult, + ImageGenerationInput, + ImageProvider, + ScriptDocument, + ScriptGenerationInput, + ScriptGenerationResult, + SpeechGenerationInput, + SpeechProvider, + StoryboardGenerationInput, + StoryboardGenerationResult, + TextProvider, +} from './types.js' + +export const scriptJsonSchema = { + type: 'object', + additionalProperties: false, + required: ['title', 'logline', 'estimatedDurationSeconds', 'scenes'], + properties: { + title: { type: 'string' }, + logline: { type: 'string' }, + estimatedDurationSeconds: { type: 'integer' }, + scenes: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['number', 'location', 'interiorExterior', 'time', 'summary', 'beats'], + properties: { + number: { type: 'integer' }, + location: { type: 'string' }, + interiorExterior: { type: 'string', enum: ['内', '外'] }, + time: { type: 'string' }, + summary: { type: 'string' }, + beats: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['type', 'text', 'character', 'parenthetical'], + properties: { + type: { type: 'string', enum: ['action', 'dialogue', 'transition'] }, + text: { type: 'string' }, + character: { type: ['string', 'null'] }, + parenthetical: { type: ['string', 'null'] }, + }, + }, + }, + }, + }, + }, + }, +} satisfies Record + +export const storyboardJsonSchema = { + type: 'object', + additionalProperties: false, + required: ['shots'], + properties: { + shots: { + type: 'array', + minItems: 1, + maxItems: 300, + items: { + type: 'object', + additionalProperties: false, + required: ['sceneNumber', 'title', 'description', 'shotType', 'cameraMotion', 'durationMs', 'characterNames', 'sceneName', 'subtitleText'], + properties: { + sceneNumber: { type: 'integer', minimum: 1 }, + title: { type: 'string' }, + description: { type: 'string' }, + shotType: { type: 'string' }, + cameraMotion: { type: 'string' }, + durationMs: { type: 'integer', minimum: 500, maximum: 30000 }, + characterNames: { type: 'array', items: { type: 'string' } }, + sceneName: { type: ['string', 'null'] }, + subtitleText: { type: 'string' }, + }, + }, + }, + }, +} satisfies Record + +export const scriptDocumentSchema = z.object({ + title: z.string().trim().min(1).max(180), + logline: z.string().max(2000), + estimatedDurationSeconds: z.number().int().positive().max(24 * 60 * 60), + scenes: z.array(z.object({ + number: z.number().int().positive(), + location: z.string().trim().min(1).max(500), + interiorExterior: z.enum(['内', '外']), + time: z.string().trim().min(1).max(160), + summary: z.string().max(4000), + beats: z.array(z.object({ + type: z.enum(['action', 'dialogue', 'transition']), + text: z.string().min(1).max(10_000), + character: z.string().max(180).nullable(), + parenthetical: z.string().max(500).nullable(), + })).max(2000), + })).min(1).max(500), +}) satisfies z.ZodType + +export const storyboardDocumentSchema = z.object({ + shots: z.array(z.object({ + sceneNumber: z.number().int().min(1), + title: z.string().trim().min(1).max(180), + description: z.string().trim().min(1).max(10_000), + shotType: z.string().trim().min(1).max(50), + cameraMotion: z.string().trim().min(1).max(50), + durationMs: z.number().int().min(500).max(30_000), + characterNames: z.array(z.string().trim().min(1).max(180)).max(20), + sceneName: z.string().trim().min(1).max(180).nullable(), + subtitleText: z.string().max(10_000), + })).min(1).max(300), +}) satisfies z.ZodType<{ shots: StoryboardGenerationResult['shots'] }> + +function responseRefusal(response: unknown) { + if (!response || typeof response !== 'object' || !('output' in response) || !Array.isArray(response.output)) return null + for (const item of response.output) { + if (!item || typeof item !== 'object' || !('content' in item) || !Array.isArray(item.content)) continue + for (const part of item.content) { + if (part && typeof part === 'object' && 'type' in part && part.type === 'refusal' && 'refusal' in part && typeof part.refusal === 'string') { + return part.refusal.slice(0, 1000) + } + } + } + return null +} + +function parseStructuredResponse(response: unknown, schema: z.ZodType, label: string): T { + if (!response || typeof response !== 'object') throw new Error(`OpenAI returned an invalid ${label} response`) + const refusal = responseRefusal(response) + if (refusal) throw new Error(`OpenAI refused the ${label} request: ${refusal}`) + const status = 'status' in response && typeof response.status === 'string' ? response.status : null + if (status && status !== 'completed') { + const reason = 'incomplete_details' in response && response.incomplete_details && typeof response.incomplete_details === 'object' + && 'reason' in response.incomplete_details && typeof response.incomplete_details.reason === 'string' + ? response.incomplete_details.reason + : status + throw new Error(`OpenAI ${label} response did not complete: ${reason}`) + } + const outputText = 'output_text' in response && typeof response.output_text === 'string' ? response.output_text : '' + if (!outputText) throw new Error(`OpenAI returned an empty ${label} response`) + let document: unknown + try { + document = JSON.parse(outputText) + } catch { + throw new Error(`OpenAI returned malformed JSON for ${label}`) + } + const parsed = schema.safeParse(document) + if (!parsed.success) throw new Error(`OpenAI returned a ${label} document that does not match the required schema`) + return parsed.data +} + +const GPT_IMAGE_2 = /^gpt-image-2(?:-|$)/ +const GPT_IMAGE_2_MIN_PIXELS = 655_360 +const GPT_IMAGE_2_MAX_PIXELS = 8_294_400 +const GPT_IMAGE_2_MAX_EDGE = 3840 + +function align16(value: number) { + return Math.max(16, Math.round(value / 16) * 16) +} + +export function openAIImageDimensions(model: string, requestedWidth: number, requestedHeight: number) { + if (!GPT_IMAGE_2.test(model)) { + if (requestedWidth > requestedHeight) return { width: 1536, height: 1024, size: '1536x1024' as const } + if (requestedHeight > requestedWidth) return { width: 1024, height: 1536, size: '1024x1536' as const } + return { width: 1024, height: 1024, size: '1024x1024' as const } + } + + let width = Math.max(16, requestedWidth) + let height = Math.max(16, requestedHeight) + if (width / height > 3) width = height * 3 + if (height / width > 3) height = width * 3 + const edgeScale = Math.min(1, GPT_IMAGE_2_MAX_EDGE / Math.max(width, height)) + width *= edgeScale + height *= edgeScale + const pixelCount = width * height + const pixelScale = pixelCount > GPT_IMAGE_2_MAX_PIXELS + ? Math.sqrt(GPT_IMAGE_2_MAX_PIXELS / pixelCount) + : pixelCount < GPT_IMAGE_2_MIN_PIXELS + ? Math.sqrt(GPT_IMAGE_2_MIN_PIXELS / pixelCount) + : 1 + width = align16(width * pixelScale) + height = align16(height * pixelScale) + while (width * height > GPT_IMAGE_2_MAX_PIXELS) { + if (width >= height) width -= 16 + else height -= 16 + } + while (width * height < GPT_IMAGE_2_MIN_PIXELS) { + if (width >= height) width += 16 + else height += 16 + } + return { width, height, size: `${width}x${height}` } +} + +function requireApiKey() { + if (!config.OPENAI_API_KEY) throw errors.badRequest('OPENAI_API_KEY is not configured') + return config.OPENAI_API_KEY +} + +export function scriptToPlainText(document: ScriptDocument) { + return document.scenes.map((scene) => { + const heading = `场 ${scene.number} · ${scene.interiorExterior} · ${scene.location} · ${scene.time}` + const beats = scene.beats.map((beat) => { + if (beat.type !== 'dialogue') return beat.text + const parenthetical = beat.parenthetical ? `(${beat.parenthetical})` : '' + return `${beat.character ?? '角色'}${parenthetical}:${beat.text}` + }) + return [heading, ...beats].join('\n') + }).join('\n\n') +} + +export class OpenAIProvider implements TextProvider, ImageProvider, SpeechProvider { + readonly name = 'openai' + private readonly client: OpenAI + + constructor(fetchImpl?: typeof fetch) { + this.client = new OpenAI({ + apiKey: requireApiKey(), + ...(fetchImpl ? { fetch: fetchImpl } : {}), + }) + } + + async generateScript(input: ScriptGenerationInput): Promise { + const response = await this.client.responses.create({ + model: config.OPENAI_TEXT_MODEL, + instructions: [ + '你是专业的中文漫剧编剧。输出严格遵循给定 JSON Schema。', + '故事必须可拍摄、镜头动作明确、对白简洁,并保持角色动机连续。', + '不要解释创作过程,不要输出 Schema 之外的字段。', + ].join('\n'), + input: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + synopsis: input.synopsis, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + existingScript: input.existingScript ?? null, + }), + text: { + format: { + type: 'json_schema', + name: 'comic_drama_script', + strict: true, + schema: scriptJsonSchema, + }, + verbosity: 'medium', + }, + safety_identifier: input.safetyIdentifier, + store: false, + }) + + const document = parseStructuredResponse(response, scriptDocumentSchema, 'script') + const usage = response.usage + + return { + document, + plainText: scriptToPlainText(document), + model: config.OPENAI_TEXT_MODEL, + usage: usage ? [ + { kind: 'TEXT_INPUT_TOKEN', quantity: usage.input_tokens, credits: usage.input_tokens / 1000 }, + { kind: 'TEXT_OUTPUT_TOKEN', quantity: usage.output_tokens, credits: usage.output_tokens / 500 }, + ] : [], + } + } + + async generateStoryboard(input: StoryboardGenerationInput): Promise { + const response = await this.client.responses.create({ + model: config.OPENAI_TEXT_MODEL, + instructions: [ + '你是专业的中文漫剧分镜导演。输出严格遵循给定 JSON Schema。', + '把完整剧本拆成可以独立生成画面和视频的镜头,画面描述必须明确人物、动作、构图、光线和环境。', + '优先使用给定的人物与场景资产名称,保持人物连续性,并让镜头总时长接近目标时长。', + 'subtitleText 只保留当前镜头需要朗读的对白或旁白;无对白时输出空字符串。', + '不要解释创作过程,不要输出 Schema 之外的字段。', + ].join('\n'), + input: JSON.stringify({ + task: input.instruction, + project: input.projectName, + genre: input.genre, + episodeTitle: input.episodeTitle, + targetDurationSeconds: input.targetDurationSeconds, + visualBible: input.visualBible, + availableCharacters: input.characterNames, + availableScenes: input.sceneNames, + script: input.script, + }), + text: { + format: { + type: 'json_schema', + name: 'comic_drama_storyboard', + strict: true, + schema: storyboardJsonSchema, + }, + verbosity: 'medium', + }, + safety_identifier: input.safetyIdentifier, + store: false, + }) + + const document = parseStructuredResponse(response, storyboardDocumentSchema, 'storyboard') + const usage = response.usage + return { + shots: document.shots, + model: config.OPENAI_TEXT_MODEL, + usage: usage ? [ + { kind: 'TEXT_INPUT_TOKEN', quantity: usage.input_tokens, credits: usage.input_tokens / 1000 }, + { kind: 'TEXT_OUTPUT_TOKEN', quantity: usage.output_tokens, credits: usage.output_tokens / 500 }, + ] : [], + } + } + + async generateImage(input: ImageGenerationInput): Promise { + const dimensions = openAIImageDimensions(config.OPENAI_IMAGE_MODEL, input.width, input.height) + const consistencyStrength = Math.max(50, Math.min(100, Number(input.params?.consistencyStrength) || 82)) + const referenceInstruction = input.references.length > 0 + ? `Use the supplied images as strict continuity references. Preserve recognizable identity, facial structure, costume signatures, scene geometry, and visual style at approximately ${consistencyStrength}% fidelity while following the new composition.` + : '' + const fullPrompt = [input.prompt, referenceInstruction, input.negativePrompt ? `Avoid: ${input.negativePrompt}` : ''].filter(Boolean).join('\n') + + const response = input.references.length > 0 + ? await this.client.images.edit({ + model: config.OPENAI_IMAGE_MODEL, + image: await Promise.all(input.references.map((reference) => toFile(reference.data, reference.name, { type: reference.mimeType }))), + prompt: fullPrompt, + size: dimensions.size, + quality: 'high', + ...(!GPT_IMAGE_2.test(config.OPENAI_IMAGE_MODEL) ? { input_fidelity: 'high' as const } : {}), + output_format: 'png', + user: input.safetyIdentifier, + }) + : await this.client.images.generate({ + model: config.OPENAI_IMAGE_MODEL, + prompt: fullPrompt, + size: dimensions.size, + quality: 'high', + output_format: 'png', + user: input.safetyIdentifier, + }) + + const image = response.data?.[0] + if (!image?.b64_json && !image?.url) throw new Error('OpenAI returned no image data') + const media = image.b64_json + ? { data: Buffer.from(image.b64_json, 'base64') } + : { url: image.url! } + return { + ...media, + mimeType: 'image/png', + model: config.OPENAI_IMAGE_MODEL, + width: dimensions.width, + height: dimensions.height, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 120 }], + } + } + + async generateSpeech(input: SpeechGenerationInput): Promise { + const response = await this.client.audio.speech.create({ + model: config.OPENAI_TTS_MODEL, + voice: input.voice ?? config.OPENAI_TTS_VOICE, + input: input.text, + response_format: 'mp3', + ...(input.instructions ? { instructions: input.instructions } : {}), + ...(input.speed !== undefined ? { speed: input.speed } : {}), + }) + const data = Buffer.from(await response.arrayBuffer()) + const estimatedSeconds = Math.max(1, input.text.length / 4.2) + return { + data, + mimeType: 'audio/mpeg', + model: config.OPENAI_TTS_MODEL, + usage: [{ kind: 'AUDIO_SECOND', quantity: estimatedSeconds, credits: estimatedSeconds * 0.6 }], + } + } +} diff --git a/server/src/providers/readiness.test.ts b/server/src/providers/readiness.test.ts new file mode 100644 index 0000000..25c97f9 --- /dev/null +++ b/server/src/providers/readiness.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { AppError } from '../lib/errors.js' +import { assertJobProviderConfigured, buildProviderReadiness, type ProviderConfiguration } from './readiness.js' + +const emptyConfiguration: ProviderConfiguration = { + openaiTextModel: 'text-model', + openaiImageModel: 'image-model', + openaiTtsModel: 'tts-model', +} + +describe('provider readiness', () => { + it('reports missing requirements without exposing credential values', () => { + const readiness = buildProviderReadiness(emptyConfiguration) + expect(readiness.status).toBe('NOT_CONFIGURED') + expect(readiness.summary).toEqual({ configuredWorkflows: 0, totalWorkflows: 6 }) + expect(readiness.workflows.find((item) => item.id === 'video')?.missingRequirements).toEqual([ + 'AGNES_API_KEY', + 'AGNES_VIDEO_MODEL', + ]) + }) + + it('distinguishes partial provider setup from configured workflows', () => { + const readiness = buildProviderReadiness({ + ...emptyConfiguration, + openaiApiKey: 'secret-openai-key', + replicateApiToken: 'secret-replicate-token', + replicateVideoVersion: 'video-version', + }) + expect(readiness.status).toBe('PARTIAL') + expect(readiness.summary.configuredWorkflows).toBe(5) + expect(readiness.providers.find((item) => item.id === 'openai')?.status).toBe('CONFIGURED') + expect(readiness.providers.find((item) => item.id === 'replicate')?.status).toBe('PARTIAL') + expect(JSON.stringify(readiness)).not.toContain('secret-openai-key') + expect(JSON.stringify(readiness)).not.toContain('secret-replicate-token') + }) + + it('rejects unavailable cloud jobs before they enter the queue', () => { + expect(() => assertJobProviderConfigured('VIDEO_GENERATE', {}, emptyConfiguration)).toThrow(AppError) + try { + assertJobProviderConfigured('VIDEO_GENERATE', {}, emptyConfiguration) + } catch (error) { + expect(error).toMatchObject({ + statusCode: 400, + code: 'BAD_REQUEST', + details: { + provider: 'replicate', + capability: 'video-replicate', + missingRequirements: ['REPLICATE_API_TOKEN', 'REPLICATE_VIDEO_VERSION'], + }, + }) + } + expect(() => assertJobProviderConfigured('EPISODE_RENDER', {}, emptyConfiguration)).not.toThrow() + }) + + it('tracks generic text, image, and video configuration independently', () => { + const configuration: ProviderConfiguration = { + ...emptyConfiguration, + genericTextApiKey: 'generic-text-secret', + genericTextBaseUrl: 'https://text.vendor.test/v1', + genericTextModel: 'text-v1', + genericImageApiKey: 'generic-image-secret', + genericImageBaseUrl: 'https://image.vendor.test/v1', + genericImageModel: 'image-v1', + genericVideoApiKey: 'generic-video-secret', + genericVideoBaseUrl: 'https://video.vendor.test/v1', + genericVideoModel: 'video-v1', + } + const readiness = buildProviderReadiness(configuration) + const generic = readiness.providers.find((item) => item.id === 'generic') + expect(generic).toMatchObject({ status: 'CONFIGURED', configuredCapabilities: 4, totalCapabilities: 4 }) + expect(readiness.workflows.find((item) => item.id === 'script')).toMatchObject({ provider: 'generic', model: 'text-v1' }) + expect(() => assertJobProviderConfigured('ASSET_GENERATE', { provider: 'generic' }, configuration)).not.toThrow() + expect(JSON.stringify(readiness)).not.toContain('generic-text-secret') + }) + + it('reports the exact missing generic capability settings', () => { + expect(() => assertJobProviderConfigured('VIDEO_GENERATE', { provider: 'generic' }, { + ...emptyConfiguration, + genericVideoApiKey: 'generic-video-secret', + })).toThrow(AppError) + try { + assertJobProviderConfigured('VIDEO_GENERATE', { provider: 'generic' }, { + ...emptyConfiguration, + genericVideoApiKey: 'generic-video-secret', + }) + } catch (error) { + expect(error).toMatchObject({ + details: { + provider: 'generic', + capability: 'video-generic', + missingRequirements: ['GENERIC_VIDEO_BASE_URL', 'GENERIC_VIDEO_MODEL'], + }, + }) + } + }) +}) diff --git a/server/src/providers/readiness.ts b/server/src/providers/readiness.ts new file mode 100644 index 0000000..04f370c --- /dev/null +++ b/server/src/providers/readiness.ts @@ -0,0 +1,280 @@ +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import type { JobKind } from '../queue/types.js' + +export interface ProviderConfiguration { + openaiApiKey?: string + openaiTextModel: string + openaiImageModel: string + openaiTtsModel: string + agnesApiKey?: string + agnesTextModel?: string + agnesImageModel?: string + agnesVideoModel?: string + genericTextApiKey?: string + genericTextBaseUrl?: string + genericTextModel?: string + genericImageApiKey?: string + genericImageBaseUrl?: string + genericImageModel?: string + genericVideoApiKey?: string + genericVideoBaseUrl?: string + genericVideoModel?: string + replicateApiToken?: string + replicateImageVersion?: string + replicateVideoVersion?: string + replicateLipsyncVersion?: string +} + +type ProviderId = 'openai' | 'replicate' | 'agnes' | 'generic' +type CapabilityId = + | 'script-openai' + | 'storyboard-openai' + | 'image-openai' + | 'tts' + | 'image-replicate' + | 'video-replicate' + | 'lipsync' + | 'script-agnes' + | 'storyboard-agnes' + | 'image-agnes' + | 'video-agnes' + | 'script-generic' + | 'storyboard-generic' + | 'image-generic' + | 'video-generic' +type WorkflowId = 'script' | 'storyboard' | 'image' | 'tts' | 'video' | 'lipsync' + +interface CapabilityDefinition { + id: CapabilityId + label: string + provider: ProviderId + model: string | undefined + requirementKeys: Array +} + +const publicRequirementNames: Partial> = { + openaiApiKey: 'OPENAI_API_KEY', + agnesApiKey: 'AGNES_API_KEY', + agnesTextModel: 'AGNES_TEXT_MODEL', + agnesImageModel: 'AGNES_IMAGE_MODEL', + agnesVideoModel: 'AGNES_VIDEO_MODEL', + genericTextApiKey: 'GENERIC_TEXT_API_KEY', + genericTextBaseUrl: 'GENERIC_TEXT_BASE_URL', + genericTextModel: 'GENERIC_TEXT_MODEL', + genericImageApiKey: 'GENERIC_IMAGE_API_KEY', + genericImageBaseUrl: 'GENERIC_IMAGE_BASE_URL', + genericImageModel: 'GENERIC_IMAGE_MODEL', + genericVideoApiKey: 'GENERIC_VIDEO_API_KEY', + genericVideoBaseUrl: 'GENERIC_VIDEO_BASE_URL', + genericVideoModel: 'GENERIC_VIDEO_MODEL', + replicateApiToken: 'REPLICATE_API_TOKEN', + replicateImageVersion: 'REPLICATE_IMAGE_VERSION', + replicateVideoVersion: 'REPLICATE_VIDEO_VERSION', + replicateLipsyncVersion: 'REPLICATE_LIPSYNC_VERSION', +} + +export function providerConfiguration(): ProviderConfiguration { + return { + ...(config.OPENAI_API_KEY ? { openaiApiKey: config.OPENAI_API_KEY } : {}), + openaiTextModel: config.OPENAI_TEXT_MODEL, + openaiImageModel: config.OPENAI_IMAGE_MODEL, + openaiTtsModel: config.OPENAI_TTS_MODEL, + ...(config.AGNES_API_KEY ? { agnesApiKey: config.AGNES_API_KEY } : {}), + agnesTextModel: config.AGNES_TEXT_MODEL, + agnesImageModel: config.AGNES_IMAGE_MODEL, + agnesVideoModel: config.AGNES_VIDEO_MODEL, + ...(config.GENERIC_TEXT_API_KEY ? { genericTextApiKey: config.GENERIC_TEXT_API_KEY } : {}), + ...(config.GENERIC_TEXT_BASE_URL ? { genericTextBaseUrl: config.GENERIC_TEXT_BASE_URL } : {}), + ...(config.GENERIC_TEXT_MODEL ? { genericTextModel: config.GENERIC_TEXT_MODEL } : {}), + ...(config.GENERIC_IMAGE_API_KEY ? { genericImageApiKey: config.GENERIC_IMAGE_API_KEY } : {}), + ...(config.GENERIC_IMAGE_BASE_URL ? { genericImageBaseUrl: config.GENERIC_IMAGE_BASE_URL } : {}), + ...(config.GENERIC_IMAGE_MODEL ? { genericImageModel: config.GENERIC_IMAGE_MODEL } : {}), + ...(config.GENERIC_VIDEO_API_KEY ? { genericVideoApiKey: config.GENERIC_VIDEO_API_KEY } : {}), + ...(config.GENERIC_VIDEO_BASE_URL ? { genericVideoBaseUrl: config.GENERIC_VIDEO_BASE_URL } : {}), + ...(config.GENERIC_VIDEO_MODEL ? { genericVideoModel: config.GENERIC_VIDEO_MODEL } : {}), + ...(config.REPLICATE_API_TOKEN ? { replicateApiToken: config.REPLICATE_API_TOKEN } : {}), + ...(config.REPLICATE_IMAGE_VERSION ? { replicateImageVersion: config.REPLICATE_IMAGE_VERSION } : {}), + ...(config.REPLICATE_VIDEO_VERSION ? { replicateVideoVersion: config.REPLICATE_VIDEO_VERSION } : {}), + ...(config.REPLICATE_LIPSYNC_VERSION ? { replicateLipsyncVersion: config.REPLICATE_LIPSYNC_VERSION } : {}), + } +} + +function capabilityDefinitions(configuration: ProviderConfiguration): CapabilityDefinition[] { + return [ + { id: 'script-openai', label: '剧本生成', provider: 'openai', model: configuration.openaiTextModel, requirementKeys: ['openaiApiKey'] }, + { id: 'storyboard-openai', label: '智能分镜', provider: 'openai', model: configuration.openaiTextModel, requirementKeys: ['openaiApiKey'] }, + { id: 'image-openai', label: '画面生成', provider: 'openai', model: configuration.openaiImageModel, requirementKeys: ['openaiApiKey'] }, + { id: 'tts', label: '语音生成', provider: 'openai', model: configuration.openaiTtsModel, requirementKeys: ['openaiApiKey'] }, + { id: 'image-replicate', label: '画面生成', provider: 'replicate', model: configuration.replicateImageVersion, requirementKeys: ['replicateApiToken', 'replicateImageVersion'] }, + { id: 'video-replicate', label: '图生视频', provider: 'replicate', model: configuration.replicateVideoVersion, requirementKeys: ['replicateApiToken', 'replicateVideoVersion'] }, + { id: 'lipsync', label: '口型同步', provider: 'replicate', model: configuration.replicateLipsyncVersion, requirementKeys: ['replicateApiToken', 'replicateLipsyncVersion'] }, + { id: 'script-agnes', label: '剧本生成', provider: 'agnes', model: configuration.agnesTextModel, requirementKeys: ['agnesApiKey', 'agnesTextModel'] }, + { id: 'storyboard-agnes', label: '智能分镜', provider: 'agnes', model: configuration.agnesTextModel, requirementKeys: ['agnesApiKey', 'agnesTextModel'] }, + { id: 'image-agnes', label: '画面生成', provider: 'agnes', model: configuration.agnesImageModel, requirementKeys: ['agnesApiKey', 'agnesImageModel'] }, + { id: 'video-agnes', label: '图生视频', provider: 'agnes', model: configuration.agnesVideoModel, requirementKeys: ['agnesApiKey', 'agnesVideoModel'] }, + { id: 'script-generic', label: '剧本生成', provider: 'generic', model: configuration.genericTextModel, requirementKeys: ['genericTextApiKey', 'genericTextBaseUrl', 'genericTextModel'] }, + { id: 'storyboard-generic', label: '智能分镜', provider: 'generic', model: configuration.genericTextModel, requirementKeys: ['genericTextApiKey', 'genericTextBaseUrl', 'genericTextModel'] }, + { id: 'image-generic', label: '画面生成', provider: 'generic', model: configuration.genericImageModel, requirementKeys: ['genericImageApiKey', 'genericImageBaseUrl', 'genericImageModel'] }, + { id: 'video-generic', label: '图生视频', provider: 'generic', model: configuration.genericVideoModel, requirementKeys: ['genericVideoApiKey', 'genericVideoBaseUrl', 'genericVideoModel'] }, + ] +} + +function configured(value: unknown) { + return typeof value === 'string' && value.trim().length > 0 +} + +function missingRequirements(definition: CapabilityDefinition, configuration: ProviderConfiguration) { + return definition.requirementKeys + .filter((key) => !configured(configuration[key])) + .map((key) => publicRequirementNames[key]) + .filter((key): key is string => Boolean(key)) +} + +function publicCapability(definition: CapabilityDefinition, configuration: ProviderConfiguration) { + const missing = missingRequirements(definition, configuration) + return { + id: definition.id, + label: definition.label, + provider: definition.provider, + model: definition.model ?? null, + status: missing.length === 0 ? 'CONFIGURED' as const : 'NOT_CONFIGURED' as const, + missingRequirements: missing, + } +} + +const workflowCandidates: Array<{ id: WorkflowId, label: string, candidates: CapabilityId[] }> = [ + { id: 'script', label: '剧本生成', candidates: ['script-agnes', 'script-generic', 'script-openai'] }, + { id: 'storyboard', label: '智能分镜', candidates: ['storyboard-agnes', 'storyboard-generic', 'storyboard-openai'] }, + { id: 'image', label: '画面生成', candidates: ['image-agnes', 'image-generic', 'image-openai', 'image-replicate'] }, + { id: 'tts', label: '语音生成', candidates: ['tts'] }, + { id: 'video', label: '图生视频', candidates: ['video-agnes', 'video-generic', 'video-replicate'] }, + { id: 'lipsync', label: '口型同步', candidates: ['lipsync'] }, +] + +export function buildProviderReadiness(configuration = providerConfiguration()) { + const definitions = capabilityDefinitions(configuration) + const capabilities = definitions.map((definition) => publicCapability(definition, configuration)) + const workflows = workflowCandidates.map((workflow) => { + const candidates = workflow.candidates.map((id) => capabilities.find((capability) => capability.id === id)!) + const selected = candidates.find((capability) => capability.status === 'CONFIGURED') ?? candidates[0]! + return { + ...selected, + id: workflow.id, + label: workflow.label, + alternatives: candidates.map((capability) => ({ + id: capability.id, + provider: capability.provider, + model: capability.model, + status: capability.status, + missingRequirements: capability.missingRequirements, + })), + } + }) + + const providerRows = ([ + { id: 'openai' as const, label: 'OpenAI' }, + { id: 'replicate' as const, label: 'Replicate' }, + { id: 'agnes' as const, label: 'Agnes AI' }, + { id: 'generic' as const, label: '通用兼容接口' }, + ]).map((provider) => { + const providerCapabilities = capabilities.filter((capability) => capability.provider === provider.id) + const configuredCount = providerCapabilities.filter((capability) => capability.status === 'CONFIGURED').length + const hasCredential = provider.id === 'openai' + ? configured(configuration.openaiApiKey) + : provider.id === 'replicate' + ? configured(configuration.replicateApiToken) + : provider.id === 'agnes' + ? configured(configuration.agnesApiKey) + : configured(configuration.genericTextApiKey) + || configured(configuration.genericImageApiKey) + || configured(configuration.genericVideoApiKey) + const status = configuredCount === providerCapabilities.length + ? 'CONFIGURED' as const + : hasCredential + ? 'PARTIAL' as const + : 'NOT_CONFIGURED' as const + return { + ...provider, + status, + verification: status === 'NOT_CONFIGURED' ? 'NOT_RUN' as const : 'CONFIGURATION_ONLY' as const, + configuredCapabilities: configuredCount, + totalCapabilities: providerCapabilities.length, + capabilities: providerCapabilities, + } + }) + + const configuredWorkflows = workflows.filter((workflow) => workflow.status === 'CONFIGURED').length + const status = configuredWorkflows === workflows.length + ? 'CONFIGURED' as const + : configuredWorkflows > 0 + ? 'PARTIAL' as const + : 'NOT_CONFIGURED' as const + + return { + status, + verificationMode: 'CONFIGURATION' as const, + summary: { configuredWorkflows, totalWorkflows: workflows.length }, + workflows, + providers: providerRows, + } +} + +function capabilityForJob(type: JobKind, payload: Record, configuration: ProviderConfiguration): CapabilityId | null { + const provider = typeof payload.provider === 'string' ? payload.provider : null + switch (type) { + case 'SCRIPT_GENERATE': + if (provider === 'agnes') return 'script-agnes' + if (provider === 'generic') return 'script-generic' + if (provider === 'openai') return 'script-openai' + if (!provider) return configured(configuration.agnesApiKey) ? 'script-agnes' : 'script-openai' + break + case 'STORYBOARD_GENERATE': + if (provider === 'agnes') return 'storyboard-agnes' + if (provider === 'generic') return 'storyboard-generic' + if (provider === 'openai') return 'storyboard-openai' + if (!provider) return configured(configuration.agnesApiKey) ? 'storyboard-agnes' : 'storyboard-openai' + break + case 'TTS_GENERATE': + if (!provider || provider === 'openai') return 'tts' + break + case 'VIDEO_GENERATE': + if (provider === 'agnes') return 'video-agnes' + if (provider === 'generic') return 'video-generic' + if (provider === 'replicate') return 'video-replicate' + if (!provider) return configured(configuration.agnesApiKey) ? 'video-agnes' : 'video-replicate' + break + case 'LIPSYNC_GENERATE': + if (!provider || provider === 'replicate') return 'lipsync' + break + case 'ASSET_GENERATE': + if (provider === 'agnes') return 'image-agnes' + if (provider === 'generic') return 'image-generic' + if (provider === 'replicate') return 'image-replicate' + if (provider === 'openai') return 'image-openai' + if (!provider) return configured(configuration.agnesApiKey) ? 'image-agnes' : 'image-openai' + break + default: + return null + } + throw errors.badRequest(`Unsupported provider ${String(provider)} for ${type}`) +} + +export function assertJobProviderConfigured( + type: JobKind, + payload: Record, + configuration = providerConfiguration(), +) { + const capabilityId = capabilityForJob(type, payload, configuration) + if (!capabilityId) return + const definition = capabilityDefinitions(configuration).find((item) => item.id === capabilityId) + if (!definition) throw new Error(`Capability definition ${capabilityId} is missing`) + const missing = missingRequirements(definition, configuration) + if (missing.length > 0) { + throw errors.badRequest(`${definition.label}服务尚未配置`, { + provider: definition.provider, + capability: capabilityId, + missingRequirements: missing, + }) + } +} diff --git a/server/src/providers/replicate.contract.test.ts b/server/src/providers/replicate.contract.test.ts new file mode 100644 index 0000000..e55acf5 --- /dev/null +++ b/server/src/providers/replicate.contract.test.ts @@ -0,0 +1,254 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { config } from '../config.js' +import { ReplicateProvider } from './replicate.js' + +const originalConfiguration = { + token: config.REPLICATE_API_TOKEN, + imageVersion: config.REPLICATE_IMAGE_VERSION, + videoVersion: config.REPLICATE_VIDEO_VERSION, + lipsyncVersion: config.REPLICATE_LIPSYNC_VERSION, + deadlineSeconds: config.REPLICATE_PREDICTION_DEADLINE_SECONDS, +} + +function predictionResponse(output: unknown, id: string) { + return new Response(JSON.stringify({ + id, + status: 'succeeded', + output, + urls: { + get: `https://api.replicate.com/v1/predictions/${id}`, + cancel: `https://api.replicate.com/v1/predictions/${id}/cancel`, + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }) +} + +function expectPredictionRequest(input: Parameters[0], init: Parameters[1], version: string) { + expect(String(input)).toBe('https://api.replicate.com/v1/predictions') + expect(init?.method).toBe('POST') + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBe('Bearer contract-replicate-token') + expect(headers.get('prefer')).toBe('wait=60') + expect(headers.get('cancel-after')).toBe('900s') + const body = JSON.parse(init?.body as string) as { version: string; input: Record } + expect(body.version).toBe(version) + return body.input +} + +beforeAll(() => { + config.REPLICATE_API_TOKEN = 'contract-replicate-token' + config.REPLICATE_IMAGE_VERSION = 'contract-image-version' + config.REPLICATE_VIDEO_VERSION = 'contract-video-version' + config.REPLICATE_LIPSYNC_VERSION = 'contract-lipsync-version' + config.REPLICATE_PREDICTION_DEADLINE_SECONDS = 900 +}) + +afterAll(() => { + config.REPLICATE_API_TOKEN = originalConfiguration.token + config.REPLICATE_IMAGE_VERSION = originalConfiguration.imageVersion + config.REPLICATE_VIDEO_VERSION = originalConfiguration.videoVersion + config.REPLICATE_LIPSYNC_VERSION = originalConfiguration.lipsyncVersion + config.REPLICATE_PREDICTION_DEADLINE_SECONDS = originalConfiguration.deadlineSeconds +}) + +describe('Replicate provider contract', () => { + it('sends image generation and continuity references and accepts array output', async () => { + const fetchMock = vi.fn(async (request, init) => { + const input = expectPredictionRequest(request, init, 'contract-image-version') + expect(input).toEqual({ + guidance: 4.5, + prompt: '雨夜中的林遥', + negative_prompt: 'extra fingers', + width: 1024, + height: 1536, + num_outputs: 1, + image: 'https://media.example/linyao.png', + reference_image: 'https://media.example/linyao.png', + }) + return predictionResponse(['https://replicate.delivery/image.png'], 'prediction-image') + }) + + const result = await new ReplicateProvider(fetchMock).generateImage({ + prompt: '雨夜中的林遥', + negativePrompt: 'extra fingers', + width: 1024, + height: 1536, + references: [], + referenceUrls: ['https://media.example/linyao.png'], + params: { + guidance: 4.5, + prompt: 'overridden prompt', + width: 8, + num_outputs: 99, + image: 'https://attacker.example/reference.png', + }, + safetyIdentifier: 'user-replicate-image', + }) + + expect(result).toMatchObject({ + url: 'https://replicate.delivery/image.png', + mimeType: 'image/png', + model: 'contract-image-version', + externalId: 'prediction-image', + }) + }) + + it('sends image-to-video parameters and accepts object URL output', async () => { + const fetchMock = vi.fn(async (request, init) => { + const input = expectPredictionRequest(request, init, 'contract-video-version') + expect(input).toEqual({ + image: 'https://media.example/shot.png', + prompt: '镜头缓慢推进', + duration: 6, + motion_strength: 0.7, + }) + return predictionResponse({ url: 'https://replicate.delivery/video.mp4' }, 'prediction-video') + }) + + const result = await new ReplicateProvider(fetchMock).generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: '镜头缓慢推进', + durationSeconds: 6, + params: { + motion_strength: 0.7, + image: 'https://attacker.example/shot.png', + prompt: 'overridden prompt', + duration: 300, + }, + }) + + expect(result.url).toBe('https://replicate.delivery/video.mp4') + expect(result.usage).toEqual([{ kind: 'VIDEO_SECOND', quantity: 6, credits: 210 }]) + }) + + it('sends video and audio inputs for lipsync and accepts string output', async () => { + const fetchMock = vi.fn(async (request, init) => { + const input = expectPredictionRequest(request, init, 'contract-lipsync-version') + expect(input).toEqual({ + video: 'https://media.example/shot.mp4', + audio: 'https://media.example/dialogue.mp3', + face_enhance: true, + }) + return predictionResponse('https://replicate.delivery/lipsync.mp4', 'prediction-lipsync') + }) + + const result = await new ReplicateProvider(fetchMock).generateLipsync({ + videoUrl: 'https://media.example/shot.mp4', + audioUrl: 'https://media.example/dialogue.mp3', + params: { + face_enhance: true, + video: 'https://attacker.example/shot.mp4', + audio: 'https://attacker.example/dialogue.mp3', + }, + }) + + expect(result).toMatchObject({ + url: 'https://replicate.delivery/lipsync.mp4', + mimeType: 'video/mp4', + externalId: 'prediction-lipsync', + }) + }) + + it('propagates terminal and HTTP failures without exposing the token', async () => { + const terminalFetch = vi.fn(async () => new Response(JSON.stringify({ + id: 'prediction-failed', + status: 'failed', + error: 'model failed safely', + urls: { + get: 'https://api.replicate.com/v1/predictions/prediction-failed', + cancel: 'https://api.replicate.com/v1/predictions/prediction-failed/cancel', + }, + }), { status: 200, headers: { 'content-type': 'application/json' } })) + await expect(new ReplicateProvider(terminalFetch).generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: 'test', + durationSeconds: 5, + })).rejects.toThrow('model failed safely') + + const httpFetch = vi.fn(async () => new Response('rate limited', { status: 429 })) + const request = new ReplicateProvider(httpFetch).generateLipsync({ + videoUrl: 'https://media.example/shot.mp4', + audioUrl: 'https://media.example/dialogue.mp3', + }) + await expect(request).rejects.toThrow('Replicate API 429: rate limited') + await expect(request).rejects.not.toThrow('contract-replicate-token') + }) + + it('rejects untrusted polling URLs before sending the bearer token', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ + id: 'prediction-polling', + status: 'processing', + urls: { + get: 'https://api.replicate.com.attacker.example/v1/predictions/prediction-polling', + cancel: 'https://api.replicate.com/v1/predictions/prediction-polling/cancel', + }, + }), { status: 200, headers: { 'content-type': 'application/json' } })) + + await expect(new ReplicateProvider(fetchMock).generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: 'test', + durationSeconds: 5, + })).rejects.toThrow('untrusted get URL') + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed prediction responses and invalid JSON', async () => { + const malformedFetch = vi.fn(async () => new Response(JSON.stringify({ + id: 'prediction-malformed', + status: 'unknown', + output: 'https://replicate.delivery/video.mp4', + }), { status: 200, headers: { 'content-type': 'application/json' } })) + await expect(new ReplicateProvider(malformedFetch).generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: 'test', + durationSeconds: 5, + })).rejects.toThrow('malformed prediction response') + + const invalidJsonFetch = vi.fn(async () => new Response('{', { status: 200 })) + await expect(new ReplicateProvider(invalidJsonFetch).generateLipsync({ + videoUrl: 'https://media.example/shot.mp4', + audioUrl: 'https://media.example/dialogue.mp3', + })).rejects.toThrow('invalid JSON') + }) + + it('bounds HTTP error details', async () => { + const fetchMock = vi.fn(async () => new Response(`provider failure ${'x'.repeat(1000)}END`, { status: 500 })) + const request = new ReplicateProvider(fetchMock).generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: 'test', + durationSeconds: 5, + }) + await expect(request).rejects.toThrow(/^Replicate API 500: provider failure/) + await expect(request).rejects.not.toThrow('END') + }) + + it('cancels a prediction when its local deadline expires', async () => { + let now = 0 + const fetchMock = vi.fn(async (request, init) => { + const url = String(request) + const canceled = url.endsWith('/cancel') && init?.method === 'POST' + return new Response(JSON.stringify({ + id: 'prediction-timeout', + status: canceled ? 'canceled' : 'processing', + urls: { + get: 'https://api.replicate.com/v1/predictions/prediction-timeout', + cancel: 'https://api.replicate.com/v1/predictions/prediction-timeout/cancel', + }, + }), { status: 200, headers: { 'content-type': 'application/json' } }) + }) + const provider = new ReplicateProvider(fetchMock, { + predictionDeadlineSeconds: 5, + pollIntervalMs: 2000, + now: () => now, + sleep: async (milliseconds) => { now += milliseconds }, + }) + + await expect(provider.generateVideo({ + imageUrl: 'https://media.example/shot.png', + prompt: 'test', + durationSeconds: 5, + })).rejects.toThrow('exceeded the 5s deadline and was canceled') + const finalCall = fetchMock.mock.calls.at(-1) + expect(String(finalCall?.[0])).toBe('https://api.replicate.com/v1/predictions/prediction-timeout/cancel') + expect(finalCall?.[1]?.method).toBe('POST') + }) +}) diff --git a/server/src/providers/replicate.ts b/server/src/providers/replicate.ts new file mode 100644 index 0000000..dde0af6 --- /dev/null +++ b/server/src/providers/replicate.ts @@ -0,0 +1,251 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { z } from 'zod' +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import type { + BinaryGenerationResult, + ImageGenerationInput, + ImageProvider, + LipsyncGenerationInput, + VideoGenerationInput, + VideoProvider, +} from './types.js' + +const replicateApiOrigin = 'https://api.replicate.com' +const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']) +const predictionSchema = z.object({ + id: z.string().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/), + status: z.enum(['starting', 'processing', 'succeeded', 'failed', 'canceled']), + output: z.unknown().optional(), + error: z.string().max(10_000).nullable().optional(), + urls: z.object({ + get: z.url().max(2048), + cancel: z.url().max(2048), + }), + metrics: z.object({ predict_time: z.number().finite().nonnegative().optional() }).passthrough().optional(), +}).passthrough() +type Prediction = z.infer + +interface ReplicateRuntimeOptions { + predictionDeadlineSeconds?: number + pollIntervalMs?: number + now?: () => number + sleep?: (milliseconds: number) => Promise +} + +function requireToken() { + if (!config.REPLICATE_API_TOKEN) throw errors.badRequest('REPLICATE_API_TOKEN is not configured') + return config.REPLICATE_API_TOKEN +} + +function requireVersion(value: string | undefined, name: string) { + if (!value) throw errors.badRequest(`${name} is not configured`) + return value +} + +function outputUrl(output: unknown) { + if (typeof output === 'string') return output + if (Array.isArray(output) && typeof output[0] === 'string') return output[0] + if (output && typeof output === 'object' && 'url' in output && typeof output.url === 'string') return output.url + throw new Error('Replicate prediction returned no output URL') +} + +function predictionApiUrl(value: string, predictionId: string, operation: 'get' | 'cancel') { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error(`Replicate prediction returned an invalid ${operation} URL`) + } + const suffix = operation === 'cancel' ? '/cancel' : '' + const expectedPath = `/v1/predictions/${predictionId}${suffix}` + if (url.origin !== replicateApiOrigin + || url.username + || url.password + || url.pathname !== expectedPath + || url.search + || url.hash) { + throw new Error(`Replicate prediction returned an untrusted ${operation} URL`) + } + return url.toString() +} + +function parsePrediction(payload: unknown) { + const parsed = predictionSchema.safeParse(payload) + if (!parsed.success) throw new Error('Replicate API returned a malformed prediction response') + return { + ...parsed.data, + urls: { + get: predictionApiUrl(parsed.data.urls.get, parsed.data.id, 'get'), + cancel: predictionApiUrl(parsed.data.urls.cancel, parsed.data.id, 'cancel'), + }, + } +} + +function protectedInput( + params: Record | undefined, + protectedKeys: string[], + core: Record, +) { + const custom = { ...(params ?? {}) } + protectedKeys.forEach((key) => delete custom[key]) + return { ...custom, ...core } +} + +async function limitedErrorText(response: Response, limit = 500) { + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let result = '' + let bytesRead = 0 + let truncated = false + try { + while (bytesRead < limit) { + const { done, value } = await reader.read() + if (done) break + const remaining = limit - bytesRead + const chunk = value.subarray(0, remaining) + result += decoder.decode(chunk, { stream: true }) + bytesRead += chunk.byteLength + if (chunk.byteLength < value.byteLength) { + truncated = true + break + } + } + result += decoder.decode() + } finally { + if (truncated || bytesRead >= limit) await reader.cancel().catch(() => undefined) + reader.releaseLock() + } + return result +} + +export class ReplicateProvider implements ImageProvider, VideoProvider { + readonly name = 'replicate' + private readonly token: string + private readonly predictionDeadlineSeconds: number + private readonly pollIntervalMs: number + private readonly now: () => number + private readonly sleep: (milliseconds: number) => Promise + + constructor( + private readonly fetchImpl: typeof fetch = fetch, + options: ReplicateRuntimeOptions = {}, + ) { + this.token = requireToken() + this.predictionDeadlineSeconds = options.predictionDeadlineSeconds ?? config.REPLICATE_PREDICTION_DEADLINE_SECONDS + this.pollIntervalMs = options.pollIntervalMs ?? 2000 + this.now = options.now ?? Date.now + this.sleep = options.sleep ?? ((milliseconds) => delay(milliseconds)) + } + + private async request(url: string, init?: RequestInit, timeoutMs = 60_000) { + const target = new URL(url) + if (target.origin !== replicateApiOrigin || target.username || target.password) { + throw new Error('Refusing to send Replicate credentials to an untrusted URL') + } + const headers = new Headers(init?.headers) + headers.set('Authorization', `Bearer ${this.token}`) + headers.set('Content-Type', 'application/json') + headers.set('Accept', 'application/json') + const response = await this.fetchImpl(url, { + ...init, + headers, + signal: AbortSignal.timeout(Math.max(1, timeoutMs)), + }) + if (!response.ok) { + const message = await limitedErrorText(response) + throw new Error(`Replicate API ${response.status}${message ? `: ${message}` : ''}`) + } + let payload: unknown + try { + payload = await response.json() + } catch { + throw new Error('Replicate API returned invalid JSON') + } + return parsePrediction(payload) + } + + private async predict(version: string, input: Record) { + const deadlineAt = this.now() + this.predictionDeadlineSeconds * 1000 + let prediction = await this.request('https://api.replicate.com/v1/predictions', { + method: 'POST', + headers: { + Prefer: 'wait=60', + 'Cancel-After': `${this.predictionDeadlineSeconds}s`, + }, + body: JSON.stringify({ version, input }), + }, Math.min(60_000, this.predictionDeadlineSeconds * 1000)) + + while (!terminalStatuses.has(prediction.status)) { + const remainingMs = deadlineAt - this.now() + if (remainingMs <= 0) { + await this.request(prediction.urls.cancel, { method: 'POST' }, 10_000).catch(() => undefined) + throw new Error(`Replicate prediction exceeded the ${this.predictionDeadlineSeconds}s deadline and was canceled`) + } + await this.sleep(Math.min(this.pollIntervalMs, remainingMs)) + const requestRemainingMs = deadlineAt - this.now() + if (requestRemainingMs <= 0) continue + prediction = await this.request(prediction.urls.get, undefined, Math.min(60_000, requestRemainingMs)) + } + + if (prediction.status !== 'succeeded') throw new Error(prediction.error || `Replicate prediction ended with ${prediction.status}`) + return prediction + } + + async generateImage(input: ImageGenerationInput): Promise { + const version = requireVersion(config.REPLICATE_IMAGE_VERSION, 'REPLICATE_IMAGE_VERSION') + const referenceUrl = input.referenceUrls[0] + const prediction = await this.predict(version, protectedInput( + input.params, + ['prompt', 'negative_prompt', 'width', 'height', 'num_outputs', 'image', 'reference_image'], + { + prompt: input.prompt, + negative_prompt: input.negativePrompt, + width: input.width, + height: input.height, + num_outputs: 1, + ...(referenceUrl ? { image: referenceUrl, reference_image: referenceUrl } : {}), + }, + )) + return { + url: outputUrl(prediction.output), + mimeType: 'image/png', + model: version, + externalId: prediction.id, + usage: [{ kind: 'IMAGE', quantity: 1, credits: 100 }], + } + } + + async generateVideo(input: VideoGenerationInput): Promise { + const version = requireVersion(config.REPLICATE_VIDEO_VERSION, 'REPLICATE_VIDEO_VERSION') + const prediction = await this.predict(version, protectedInput( + input.params, + ['image', 'prompt', 'duration'], + { image: input.imageUrl, prompt: input.prompt, duration: input.durationSeconds }, + )) + return { + url: outputUrl(prediction.output), + mimeType: 'video/mp4', + model: version, + externalId: prediction.id, + usage: [{ kind: 'VIDEO_SECOND', quantity: input.durationSeconds, credits: input.durationSeconds * 35 }], + } + } + + async generateLipsync(input: LipsyncGenerationInput): Promise { + const version = requireVersion(config.REPLICATE_LIPSYNC_VERSION, 'REPLICATE_LIPSYNC_VERSION') + const prediction = await this.predict(version, protectedInput( + input.params, + ['video', 'audio'], + { video: input.videoUrl, audio: input.audioUrl }, + )) + return { + url: outputUrl(prediction.output), + mimeType: 'video/mp4', + model: version, + externalId: prediction.id, + usage: [{ kind: 'VIDEO_SECOND', quantity: 1, credits: 60 }], + } + } +} diff --git a/server/src/providers/types.ts b/server/src/providers/types.ts new file mode 100644 index 0000000..7d33ac0 --- /dev/null +++ b/server/src/providers/types.ts @@ -0,0 +1,154 @@ +export interface ProviderUsage { + kind: 'TEXT_INPUT_TOKEN' | 'TEXT_OUTPUT_TOKEN' | 'IMAGE' | 'AUDIO_SECOND' | 'VIDEO_SECOND' + quantity: number + credits: number + providerCostUsd?: number +} + +export interface ScriptSceneBeat { + type: 'action' | 'dialogue' | 'transition' + text: string + character: string | null + parenthetical: string | null +} + +export interface ScriptDocument { + title: string + logline: string + estimatedDurationSeconds: number + scenes: Array<{ + number: number + location: string + interiorExterior: '内' | '外' + time: string + summary: string + beats: ScriptSceneBeat[] + }> +} + +export interface ScriptGenerationInput { + instruction: string + projectName: string + genre: string + visualBible: Record + episodeTitle: string + synopsis: string + targetDurationSeconds: number + existingScript?: string + safetyIdentifier: string +} + +export interface ScriptGenerationResult { + document: ScriptDocument + plainText: string + model: string + usage: ProviderUsage[] +} + +export interface StoryboardShot { + sceneNumber: number + title: string + description: string + shotType: string + cameraMotion: string + durationMs: number + characterNames: string[] + sceneName: string | null + subtitleText: string +} + +export interface StoryboardGenerationInput { + projectName: string + genre: string + visualBible: Record + episodeTitle: string + targetDurationSeconds: number + script: string + instruction: string + characterNames: string[] + sceneNames: string[] + safetyIdentifier: string +} + +export interface StoryboardGenerationResult { + shots: StoryboardShot[] + model: string + usage: ProviderUsage[] +} + +export interface ImageReference { + name: string + mimeType: string + data: Buffer +} + +export interface ImageGenerationInput { + prompt: string + negativePrompt?: string + width: number + height: number + references: ImageReference[] + referenceUrls: string[] + params?: Record + safetyIdentifier: string +} + +export interface BinaryGenerationResult { + data?: Buffer + url?: string + sourceUrl?: string + mimeType: string + model: string + width?: number + height?: number + externalId?: string + seed?: string + usage: ProviderUsage[] +} + +export interface SpeechGenerationInput { + text: string + voice?: string + instructions?: string + speed?: number + safetyIdentifier: string +} + +export interface VideoGenerationInput { + prompt: string + imageUrl: string + imageData?: Buffer + imageMimeType?: string + sourceWidth?: number + sourceHeight?: number + durationSeconds: number + params?: Record +} + +export interface LipsyncGenerationInput { + videoUrl: string + audioUrl: string + params?: Record +} + +export interface TextProvider { + readonly name: string + generateScript(input: ScriptGenerationInput): Promise + generateStoryboard(input: StoryboardGenerationInput): Promise +} + +export interface ImageProvider { + readonly name: string + generateImage(input: ImageGenerationInput): Promise +} + +export interface SpeechProvider { + readonly name: string + generateSpeech(input: SpeechGenerationInput): Promise +} + +export interface VideoProvider { + readonly name: string + generateVideo(input: VideoGenerationInput): Promise + generateLipsync(input: LipsyncGenerationInput): Promise +} diff --git a/server/src/providers/verification.test.ts b/server/src/providers/verification.test.ts new file mode 100644 index 0000000..a8e18cb --- /dev/null +++ b/server/src/providers/verification.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ProviderConfiguration } from './readiness.js' +import { + providerCredentialConfigured, + providerVerificationFingerprint, + verifyProviderConnection, +} from './verification.js' + +const configuration: ProviderConfiguration = { + openaiApiKey: 'openai-verification-secret', + openaiTextModel: 'text-model', + openaiImageModel: 'image-model', + openaiTtsModel: 'text-model', + agnesApiKey: 'agnes-verification-secret', + agnesTextModel: 'agnes-2.5-flash', + agnesImageModel: 'agnes-image-2.1-flash', + agnesVideoModel: 'agnes-video-v2.0', + replicateApiToken: 'replicate-verification-secret', + replicateImageVersion: 'image-version', + replicateVideoVersion: 'video-version', + replicateLipsyncVersion: 'lipsync-version', + genericTextApiKey: 'generic-text-verification-secret', + genericTextBaseUrl: 'https://text.vendor.test/v1', + genericTextModel: 'generic-text-model', + genericImageApiKey: 'generic-image-verification-secret', + genericImageBaseUrl: 'https://image.vendor.test/v1', + genericImageModel: 'generic-image-model', + genericVideoApiKey: 'generic-video-verification-secret', + genericVideoBaseUrl: 'https://video.vendor.test/v1', + genericVideoModel: 'generic-video-model', +} + +function jsonResponse(payload: unknown, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('provider connection verification', () => { + it('retrieves every distinct configured OpenAI model with bearer authentication', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const model = decodeURIComponent(new URL(String(url)).pathname.split('/').at(-1)!) + return jsonResponse({ id: model, created: 1, object: 'model', owned_by: 'openai' }) + }) + + const result = await verifyProviderConnection('openai', configuration, fetchMock as typeof fetch) + + expect(result).toMatchObject({ + status: 'VERIFIED', + errorCode: null, + details: { scope: 'credentials_and_models', models: ['text-model', 'image-model'] }, + }) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://api.openai.com/v1/models/text-model', + 'https://api.openai.com/v1/models/image-model', + ]) + for (const [, init] of fetchMock.mock.calls) { + expect(init?.method).toBe('GET') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer openai-verification-secret') + expect(init?.redirect).toBe('error') + } + }) + + it('verifies Replicate through the authenticated account endpoint', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ type: 'organization', name: 'FrameFlow' })) + const result = await verifyProviderConnection('replicate', configuration, fetchMock as typeof fetch) + + expect(result).toMatchObject({ + status: 'VERIFIED', + details: { scope: 'credentials', accountType: 'organization' }, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe('https://api.replicate.com/v1/account') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer replicate-verification-secret') + }) + + it('verifies every configured Agnes model through the authenticated model list', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ + object: 'list', + data: [ + { id: 'agnes-2.5-flash', object: 'model' }, + { id: 'agnes-image-2.1-flash', object: 'model' }, + { id: 'agnes-video-v2.0', object: 'model' }, + ], + })) + + const result = await verifyProviderConnection('agnes', configuration, fetchMock as typeof fetch) + + expect(result).toMatchObject({ + status: 'VERIFIED', + errorCode: null, + details: { + scope: 'credentials_and_models', + models: ['agnes-2.5-flash', 'agnes-image-2.1-flash', 'agnes-video-v2.0'], + }, + }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(String(url)).toBe('https://api.agnes-ai.cn/v1/models') + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer agnes-verification-secret') + }) + + it('verifies each independently configured generic capability', async () => { + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const model = decodeURIComponent(new URL(String(url)).pathname.split('/').at(-1)!) + const hostname = new URL(String(url)).hostname + const expectedKey = hostname.startsWith('text.') + ? 'generic-text-verification-secret' + : hostname.startsWith('image.') + ? 'generic-image-verification-secret' + : 'generic-video-verification-secret' + expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${expectedKey}`) + return jsonResponse({ id: model, object: 'model' }) + }) + + const result = await verifyProviderConnection('generic', configuration, fetchMock as typeof fetch) + + expect(result).toMatchObject({ + status: 'VERIFIED', + details: { + scope: 'credentials_and_models', + capabilities: [ + { capability: 'text', model: 'generic-text-model' }, + { capability: 'image', model: 'generic-image-model' }, + { capability: 'video', model: 'generic-video-model' }, + ], + }, + }) + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://text.vendor.test/v1/models/generic-text-model', + 'https://image.vendor.test/v1/models/generic-image-model', + 'https://video.vendor.test/v1/models/generic-video-model', + ]) + }) + + it.each([ + { + label: 'provider authentication failure', + fetchImpl: async () => new Response('openai-verification-secret', { status: 401 }), + code: 'AUTHENTICATION_FAILED', + }, + { + label: 'timeout', + fetchImpl: async () => { throw Object.assign(new Error('openai-verification-secret'), { name: 'TimeoutError' }) }, + code: 'TIMEOUT', + }, + { + label: 'malformed JSON', + fetchImpl: async () => new Response('{not-json', { status: 200, headers: { 'content-type': 'application/json' } }), + code: 'INVALID_RESPONSE', + }, + ])('returns a bounded sanitized result for $label', async ({ fetchImpl, code }) => { + const result = await verifyProviderConnection('openai', configuration, fetchImpl as typeof fetch) + expect(result.status).toBe('FAILED') + expect(result.errorCode).toBe(code) + expect(JSON.stringify(result)).not.toContain('openai-verification-secret') + expect(JSON.stringify(result).length).toBeLessThan(1000) + }) + + it('detects missing credentials without calling the network', async () => { + const fetchMock = vi.fn() + const missing = { ...configuration, openaiApiKey: undefined } + expect(providerCredentialConfigured('openai', missing)).toBe(false) + const result = await verifyProviderConnection('openai', missing, fetchMock as typeof fetch) + expect(result).toMatchObject({ status: 'FAILED', errorCode: 'NOT_CONFIGURED' }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('invalidates persisted results when credentials or model configuration changes', () => { + const fingerprint = providerVerificationFingerprint('openai', configuration) + expect(fingerprint).toHaveLength(64) + expect(providerVerificationFingerprint('openai', { ...configuration })).toBe(fingerprint) + expect(providerVerificationFingerprint('openai', { ...configuration, openaiApiKey: 'rotated-secret' })).not.toBe(fingerprint) + expect(providerVerificationFingerprint('openai', { ...configuration, openaiImageModel: 'next-image-model' })).not.toBe(fingerprint) + + const agnesFingerprint = providerVerificationFingerprint('agnes', configuration) + expect(agnesFingerprint).toHaveLength(64) + expect(providerVerificationFingerprint('agnes', { ...configuration })).toBe(agnesFingerprint) + expect(providerVerificationFingerprint('agnes', { ...configuration, agnesApiKey: 'rotated-secret' })).not.toBe(agnesFingerprint) + expect(providerVerificationFingerprint('agnes', { ...configuration, agnesVideoModel: 'next-video-model' })).not.toBe(agnesFingerprint) + + const genericFingerprint = providerVerificationFingerprint('generic', configuration) + expect(genericFingerprint).toHaveLength(64) + expect(providerVerificationFingerprint('generic', { ...configuration })).toBe(genericFingerprint) + expect(providerVerificationFingerprint('generic', { ...configuration, genericVideoModel: 'next-video-model' })).not.toBe(genericFingerprint) + }) +}) diff --git a/server/src/providers/verification.ts b/server/src/providers/verification.ts new file mode 100644 index 0000000..a7284e4 --- /dev/null +++ b/server/src/providers/verification.ts @@ -0,0 +1,283 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import type { ProviderConfiguration } from './readiness.js' + +export const providerIds = ['openai', 'replicate', 'agnes', 'generic'] as const +export type ProviderId = typeof providerIds[number] + +export interface ProviderVerificationResult { + status: 'VERIFIED' | 'FAILED' + latencyMs: number + errorCode: string | null + message: string | null + details: Record +} + +const openAIModelSchema = z.object({ + id: z.string().trim().min(1).max(255), + created: z.number().int().nonnegative(), + object: z.literal('model'), + owned_by: z.string().trim().min(1).max(255), +}).passthrough() + +const replicateAccountSchema = z.object({ + type: z.enum(['user', 'organization']), +}).passthrough() + +const agnesModelListSchema = z.object({ + object: z.literal('list'), + data: z.array(z.object({ + id: z.string().trim().min(1).max(255), + object: z.literal('model'), + }).passthrough()), +}).passthrough() + +const genericModelSchema = z.object({ + id: z.string().trim().min(1).max(255), + object: z.string().trim().min(1).max(80).optional(), +}).passthrough() + +class VerificationFailure extends Error { + constructor(readonly code: string, message: string) { + super(message) + this.name = 'VerificationFailure' + } +} + +function elapsedMs(startedAt: number) { + return Math.max(0, Math.round(performance.now() - startedAt)) +} + +function httpFailure(provider: ProviderId, status: number) { + const label = provider === 'openai' + ? 'OpenAI' + : provider === 'replicate' + ? 'Replicate' + : provider === 'agnes' + ? 'Agnes AI' + : '通用兼容接口' + if (status === 401 || status === 403) return new VerificationFailure('AUTHENTICATION_FAILED', `${label} 凭证无效或无权访问`) + if (status === 404) return new VerificationFailure('MODEL_NOT_FOUND', `${label} 模型或验证端点不可访问`) + if (status === 429) return new VerificationFailure('RATE_LIMITED', `${label} 暂时限流,请稍后重试`) + if (status >= 500) return new VerificationFailure('PROVIDER_UNAVAILABLE', `${label} 服务暂时不可用`) + return new VerificationFailure('PROVIDER_REJECTED', `${label} 拒绝了验证请求`) +} + +async function requestJson(input: { + provider: ProviderId + url: string + credential: string + fetchImpl: typeof fetch + signal: AbortSignal +}) { + let response: Response + try { + response = await input.fetchImpl(input.url, { + method: 'GET', + headers: { + Authorization: `Bearer ${input.credential}`, + Accept: 'application/json', + 'User-Agent': 'FrameFlow-Provider-Verification/1.0', + }, + redirect: 'error', + signal: input.signal, + }) + } catch (error) { + const name = error && typeof error === 'object' && 'name' in error ? error.name : null + if (name === 'TimeoutError' || name === 'AbortError') { + throw new VerificationFailure('TIMEOUT', '供应商连接验证超时') + } + throw new VerificationFailure('NETWORK_ERROR', '无法连接供应商验证端点') + } + if (!response.ok) throw httpFailure(input.provider, response.status) + try { + return await response.json() as unknown + } catch { + throw new VerificationFailure('INVALID_RESPONSE', '供应商返回了无法解析的验证响应') + } +} + +export function providerCredentialConfigured(provider: ProviderId, configuration: ProviderConfiguration) { + if (provider === 'openai') return Boolean(configuration.openaiApiKey?.trim()) + if (provider === 'replicate') return Boolean(configuration.replicateApiToken?.trim()) + if (provider === 'agnes') return Boolean(configuration.agnesApiKey?.trim()) + return Boolean( + configuration.genericTextApiKey?.trim() + || configuration.genericImageApiKey?.trim() + || configuration.genericVideoApiKey?.trim(), + ) +} + +export function providerVerificationFingerprint(provider: ProviderId, configuration: ProviderConfiguration) { + const values = provider === 'openai' + ? [provider, configuration.openaiApiKey ?? '', configuration.openaiTextModel, configuration.openaiImageModel, configuration.openaiTtsModel] + : provider === 'replicate' + ? [provider, configuration.replicateApiToken ?? '', configuration.replicateImageVersion ?? '', configuration.replicateVideoVersion ?? '', configuration.replicateLipsyncVersion ?? ''] + : provider === 'agnes' + ? [provider, configuration.agnesApiKey ?? '', configuration.agnesTextModel ?? '', configuration.agnesImageModel ?? '', configuration.agnesVideoModel ?? ''] + : [ + provider, + configuration.genericTextApiKey ?? '', configuration.genericTextBaseUrl ?? '', configuration.genericTextModel ?? '', + configuration.genericImageApiKey ?? '', configuration.genericImageBaseUrl ?? '', configuration.genericImageModel ?? '', + configuration.genericVideoApiKey ?? '', configuration.genericVideoBaseUrl ?? '', configuration.genericVideoModel ?? '', + ] + return createHash('sha256').update(JSON.stringify(values)).digest('hex') +} + +export async function verifyProviderConnection( + provider: ProviderId, + configuration: ProviderConfiguration, + fetchImpl: typeof fetch = fetch, +): Promise { + const startedAt = performance.now() + try { + if (!providerCredentialConfigured(provider, configuration)) { + const requirement = provider === 'openai' + ? 'OPENAI_API_KEY' + : provider === 'replicate' + ? 'REPLICATE_API_TOKEN' + : provider === 'agnes' + ? 'AGNES_API_KEY' + : 'GENERIC_TEXT_API_KEY、GENERIC_IMAGE_API_KEY 或 GENERIC_VIDEO_API_KEY' + throw new VerificationFailure('NOT_CONFIGURED', `${requirement} 尚未配置`) + } + const signal = AbortSignal.timeout(15_000) + if (provider === 'openai') { + const credential = configuration.openaiApiKey! + const models = [...new Set([ + configuration.openaiTextModel, + configuration.openaiImageModel, + configuration.openaiTtsModel, + ])] + const verifiedModels: string[] = [] + for (const model of models) { + const payload = await requestJson({ + provider, + url: `https://api.openai.com/v1/models/${encodeURIComponent(model)}`, + credential, + fetchImpl, + signal, + }) + const parsed = openAIModelSchema.safeParse(payload) + if (!parsed.success) throw new VerificationFailure('INVALID_RESPONSE', 'OpenAI 返回了不符合契约的模型响应') + verifiedModels.push(model) + } + return { + status: 'VERIFIED', + latencyMs: elapsedMs(startedAt), + errorCode: null, + message: null, + details: { scope: 'credentials_and_models', models: verifiedModels }, + } + } + + if (provider === 'agnes') { + const payload = await requestJson({ + provider, + url: 'https://api.agnes-ai.cn/v1/models', + credential: configuration.agnesApiKey!, + fetchImpl, + signal, + }) + const parsed = agnesModelListSchema.safeParse(payload) + if (!parsed.success) throw new VerificationFailure('INVALID_RESPONSE', 'Agnes AI 返回了不符合契约的模型清单') + const available = new Set(parsed.data.data.map((model) => model.id)) + const configuredModels = [...new Set([ + configuration.agnesTextModel, + configuration.agnesImageModel, + configuration.agnesVideoModel, + ].filter((model): model is string => Boolean(model)))] + const missing = configuredModels.filter((model) => !available.has(model)) + if (missing.length > 0) throw new VerificationFailure('MODEL_NOT_FOUND', `Agnes AI 模型不可访问:${missing.join('、')}`) + return { + status: 'VERIFIED', + latencyMs: elapsedMs(startedAt), + errorCode: null, + message: null, + details: { scope: 'credentials_and_models', models: configuredModels }, + } + } + + if (provider === 'generic') { + const capabilities = [ + { + capability: 'text', + apiKey: configuration.genericTextApiKey, + baseUrl: configuration.genericTextBaseUrl, + model: configuration.genericTextModel, + }, + { + capability: 'image', + apiKey: configuration.genericImageApiKey, + baseUrl: configuration.genericImageBaseUrl, + model: configuration.genericImageModel, + }, + { + capability: 'video', + apiKey: configuration.genericVideoApiKey, + baseUrl: configuration.genericVideoBaseUrl, + model: configuration.genericVideoModel, + }, + ].filter((item) => item.apiKey && item.baseUrl && item.model) as Array<{ + capability: string + apiKey: string + baseUrl: string + model: string + }> + if (capabilities.length === 0) { + throw new VerificationFailure('NOT_CONFIGURED', '通用兼容接口至少需要一组完整的 API_KEY、BASE_URL 和 MODEL') + } + const verified: Array<{ capability: string; model: string }> = [] + for (const capability of capabilities) { + const modelUrl = `${capability.baseUrl.replace(/\/$/, '')}/models/${encodeURIComponent(capability.model)}` + const payload = await requestJson({ + provider, + url: modelUrl, + credential: capability.apiKey, + fetchImpl, + signal, + }) + const parsed = genericModelSchema.safeParse(payload) + if (!parsed.success || parsed.data.id !== capability.model) { + throw new VerificationFailure('INVALID_RESPONSE', `通用${capability.capability}接口返回了不符合契约的模型响应`) + } + verified.push({ capability: capability.capability, model: capability.model }) + } + return { + status: 'VERIFIED', + latencyMs: elapsedMs(startedAt), + errorCode: null, + message: null, + details: { scope: 'credentials_and_models', capabilities: verified }, + } + } + + const payload = await requestJson({ + provider, + url: 'https://api.replicate.com/v1/account', + credential: configuration.replicateApiToken!, + fetchImpl, + signal, + }) + const parsed = replicateAccountSchema.safeParse(payload) + if (!parsed.success) throw new VerificationFailure('INVALID_RESPONSE', 'Replicate 返回了不符合契约的账号响应') + return { + status: 'VERIFIED', + latencyMs: elapsedMs(startedAt), + errorCode: null, + message: null, + details: { scope: 'credentials', accountType: parsed.data.type }, + } + } catch (error) { + const failure = error instanceof VerificationFailure + ? error + : new VerificationFailure('VERIFICATION_FAILED', '供应商连接验证失败') + return { + status: 'FAILED', + latencyMs: elapsedMs(startedAt), + errorCode: failure.code, + message: failure.message, + details: {}, + } + } +} diff --git a/server/src/publishing/connector.test.ts b/server/src/publishing/connector.test.ts new file mode 100644 index 0000000..7f3bff8 --- /dev/null +++ b/server/src/publishing/connector.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { ConnectorPublishingAdapter, connectorWebhookSignature, verifyConnectorWebhookSignature } from './connector.js' + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) +} + +describe('publishing connector adapter', () => { + it('sends authenticated platform-scoped OAuth and publishing requests', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce(jsonResponse({ authorizationUrl: 'https://open.example.cn/oauth', codeVerifier: 'v'.repeat(48) })) + .mockResolvedValueOnce(jsonResponse({ + externalAccountId: 'creator-1', + displayName: '创作者一号', + accessToken: 'access-one', + refreshToken: 'refresh-one', + tokenExpiresAt: '2026-08-01T00:00:00.000Z', + scopes: ['video.publish'], + })) + .mockResolvedValueOnce(jsonResponse({ status: 'PUBLISHING', externalId: 'video-1' })) + .mockResolvedValueOnce(jsonResponse({ revoked: true })) + const adapter = new ConnectorPublishingAdapter('DOUYIN', { + url: 'https://publisher.frameflow.cn/root', + secret: 'connector-secret-with-32-characters', + timeoutMs: 5000, + }, fetchMock) + + const authorization = await adapter.createAuthorization({ state: 'state-one', callbackUrl: 'https://studio.frameflow.cn/callback' }) + expect(authorization.codeVerifier).toHaveLength(48) + const account = await adapter.exchangeAuthorization({ code: 'code-one', callbackUrl: 'https://studio.frameflow.cn/callback', codeVerifier: authorization.codeVerifier }) + const published = await adapter.publish({ + deliveryId: crypto.randomUUID(), + sourceUrl: 'https://media.frameflow.cn/render.mp4?signature=secret', + callbackUrl: 'https://studio.frameflow.cn/webhook', + account: { externalAccountId: account.externalAccountId, accessToken: account.accessToken, refreshToken: account.refreshToken }, + destination: { title: '第一集' }, + }) + await adapter.revoke({ externalAccountId: account.externalAccountId, accessToken: account.accessToken }) + + expect(published).toEqual({ status: 'PUBLISHING', externalId: 'video-1' }) + expect(fetchMock).toHaveBeenCalledTimes(4) + for (const call of fetchMock.mock.calls) { + const init = call[1] + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer connector-secret-with-32-characters') + expect(JSON.parse(String(init?.body)).platform).toBe('DOUYIN') + } + expect(String(fetchMock.mock.calls[0]?.[0])).toBe('https://publisher.frameflow.cn/v1/oauth/authorize') + expect(String(fetchMock.mock.calls[2]?.[0])).toBe('https://publisher.frameflow.cn/v1/publish') + }) + + it('reports missing connector configuration and authenticates webhook bytes', () => { + const adapter = new ConnectorPublishingAdapter('BILIBILI', { timeoutMs: 5000 }) + expect(adapter.readiness()).toEqual({ + status: 'NOT_CONFIGURED', + missingRequirements: ['PUBLISHING_CONNECTOR_URL', 'PUBLISHING_CONNECTOR_SECRET'], + }) + const payload = Buffer.from('{"eventId":"event-1"}') + const secret = 'webhook-secret-with-at-least-32-characters' + const signature = connectorWebhookSignature(payload, secret) + expect(verifyConnectorWebhookSignature(payload, signature, secret)).toBe(true) + expect(verifyConnectorWebhookSignature(Buffer.from(`${payload} `), signature, secret)).toBe(false) + }) +}) diff --git a/server/src/publishing/connector.ts b/server/src/publishing/connector.ts new file mode 100644 index 0000000..a050127 --- /dev/null +++ b/server/src/publishing/connector.ts @@ -0,0 +1,164 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import { z } from 'zod' +import { config } from '../config.js' +import { errors } from '../lib/errors.js' +import { + externalPublishingPlatforms, + type ExternalPublishingPlatform, + type PublishingAccountCredentials, + type PublishingAdapter, + type PublishingAdapterRegistry, + type PublishingAuthorizationResult, + type PublishingResult, +} from './types.js' + +const authorizationResponse = z.object({ + authorizationUrl: z.url(), + codeVerifier: z.string().min(32).max(512).optional(), +}) +const accountResponse = z.object({ + externalAccountId: z.string().min(1).max(255), + displayName: z.string().min(1).max(180), + avatarUrl: z.url().optional(), + accessToken: z.string().min(1), + refreshToken: z.string().min(1).optional(), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + scopes: z.array(z.string().max(160)).max(100).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}) +const publishResponse = z.object({ + status: z.enum(['PUBLISHING', 'PUBLISHED']), + externalId: z.string().min(1).max(255), + publishedUrl: z.url().optional(), + credentials: z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1).optional(), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + scopes: z.array(z.string().max(160)).max(100).optional(), + }).optional(), +}) + +type ConnectorConfiguration = { + url?: string + secret?: string + timeoutMs: number +} + +function connectorConfiguration(): ConnectorConfiguration { + return { + ...(config.PUBLISHING_CONNECTOR_URL ? { url: config.PUBLISHING_CONNECTOR_URL } : {}), + ...(config.PUBLISHING_CONNECTOR_SECRET ? { secret: config.PUBLISHING_CONNECTOR_SECRET } : {}), + timeoutMs: config.PUBLISHING_CONNECTOR_TIMEOUT_MS, + } +} + +export class ConnectorPublishingAdapter implements PublishingAdapter { + constructor( + readonly platform: ExternalPublishingPlatform, + private readonly configuration: ConnectorConfiguration = connectorConfiguration(), + private readonly fetchImpl: typeof fetch = fetch, + ) {} + + readiness() { + const missingRequirements = [ + ...(!this.configuration.url ? ['PUBLISHING_CONNECTOR_URL'] : []), + ...(!this.configuration.secret ? ['PUBLISHING_CONNECTOR_SECRET'] : []), + ] + return { + status: missingRequirements.length === 0 ? 'CONFIGURED' as const : 'NOT_CONFIGURED' as const, + missingRequirements, + } + } + + private async call(path: string, body: Record, schema: z.ZodType): Promise { + const readiness = this.readiness() + if (readiness.status !== 'CONFIGURED' || !this.configuration.url || !this.configuration.secret) { + throw errors.serviceUnavailable(`${this.platform} publishing connector is not configured`, { + platform: this.platform, + missingRequirements: readiness.missingRequirements, + }) + } + const response = await this.fetchImpl(new URL(path, `${this.configuration.url.replace(/\/$/, '')}/`), { + method: 'POST', + headers: { + authorization: `Bearer ${this.configuration.secret}`, + 'content-type': 'application/json', + 'user-agent': 'FrameFlow-Publishing/1.0', + }, + body: JSON.stringify({ platform: this.platform, ...body }), + signal: AbortSignal.timeout(this.configuration.timeoutMs), + }) + if (!response.ok) { + const message = (await response.text()).slice(0, 500) + throw new Error(`Publishing connector returned ${response.status}${message ? `: ${message}` : ''}`) + } + return schema.parse(await response.json()) + } + + async createAuthorization(input: { state: string; callbackUrl: string }): Promise { + const result = await this.call('/v1/oauth/authorize', input, authorizationResponse) + return { + authorizationUrl: result.authorizationUrl, + ...(result.codeVerifier ? { codeVerifier: result.codeVerifier } : {}), + } + } + + async exchangeAuthorization(input: { code: string; callbackUrl: string; codeVerifier?: string }): Promise { + const result = await this.call('/v1/oauth/exchange', input, accountResponse) + return { + externalAccountId: result.externalAccountId, + displayName: result.displayName, + accessToken: result.accessToken, + ...(result.avatarUrl ? { avatarUrl: result.avatarUrl } : {}), + ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}), + ...(result.tokenExpiresAt ? { tokenExpiresAt: result.tokenExpiresAt } : {}), + ...(result.scopes ? { scopes: result.scopes } : {}), + ...(result.metadata ? { metadata: result.metadata } : {}), + } + } + + publish(input: { + deliveryId: string + sourceUrl: string + callbackUrl: string + account: { externalAccountId: string; accessToken: string; refreshToken?: string } + destination: Record + }): Promise { + return this.call('/v1/publish', input, publishResponse).then((result) => ({ + status: result.status, + externalId: result.externalId, + ...(result.publishedUrl ? { publishedUrl: result.publishedUrl } : {}), + ...(result.credentials ? { + credentials: { + accessToken: result.credentials.accessToken, + ...(result.credentials.refreshToken ? { refreshToken: result.credentials.refreshToken } : {}), + ...(result.credentials.tokenExpiresAt ? { tokenExpiresAt: result.credentials.tokenExpiresAt } : {}), + ...(result.credentials.scopes ? { scopes: result.credentials.scopes } : {}), + }, + } : {}), + })) + } + + async revoke(input: { externalAccountId: string; accessToken: string; refreshToken?: string }) { + await this.call('/v1/oauth/revoke', input, z.object({ revoked: z.boolean() })) + } +} + +export function createPublishingAdapterRegistry(): PublishingAdapterRegistry { + return new Map(externalPublishingPlatforms.map((platform) => [platform, new ConnectorPublishingAdapter(platform)])) +} + +export function publishingConnectorCallbackUrl() { + return new URL('/api/v1/publishing/webhooks/connector', `${config.API_PUBLIC_ORIGIN}/`).toString() +} + +export function connectorWebhookSignature(payload: Buffer | string, secret = config.PUBLISHING_CONNECTOR_SECRET ?? '') { + return `sha256=${createHmac('sha256', secret).update(payload).digest('hex')}` +} + +export function verifyConnectorWebhookSignature(payload: Buffer, signature: string | undefined, secret = config.PUBLISHING_CONNECTOR_SECRET) { + if (!secret || !signature) return false + const expected = Buffer.from(connectorWebhookSignature(payload, secret)) + const actual = Buffer.from(signature) + return expected.length === actual.length && timingSafeEqual(expected, actual) +} diff --git a/server/src/publishing/types.ts b/server/src/publishing/types.ts new file mode 100644 index 0000000..a4cd58c --- /dev/null +++ b/server/src/publishing/types.ts @@ -0,0 +1,71 @@ +export const externalPublishingPlatforms = ['DOUYIN', 'KUAISHOU', 'BILIBILI', 'XIAOHONGSHU'] as const +export type ExternalPublishingPlatform = typeof externalPublishingPlatforms[number] +export type PublishingPlatform = 'DOWNLOAD' | ExternalPublishingPlatform + +export const publishingPlatformDetails: Record = { + DOWNLOAD: { label: '下载交付', description: '生成限时安全下载链接' }, + DOUYIN: { label: '抖音', description: '发布到已授权的抖音创作者账号' }, + KUAISHOU: { label: '快手', description: '发布到已授权的快手创作者账号' }, + BILIBILI: { label: 'B 站', description: '发布到已授权的哔哩哔哩账号' }, + XIAOHONGSHU: { label: '小红书', description: '发布到已授权的小红书专业号' }, +} + +export interface PublishingAdapterReadiness { + status: 'CONFIGURED' | 'NOT_CONFIGURED' + missingRequirements: string[] +} + +export interface PublishingAuthorizationResult { + authorizationUrl: string + codeVerifier?: string +} + +export interface PublishingAccountCredentials { + externalAccountId: string + displayName: string + avatarUrl?: string + accessToken: string + refreshToken?: string + tokenExpiresAt?: string + scopes?: string[] + metadata?: Record +} + +export interface PublishingResult { + status: 'PUBLISHING' | 'PUBLISHED' + externalId: string + publishedUrl?: string + credentials?: Pick +} + +export interface PublishingAdapter { + readonly platform: ExternalPublishingPlatform + readiness(): PublishingAdapterReadiness + createAuthorization(input: { + state: string + callbackUrl: string + }): Promise + exchangeAuthorization(input: { + code: string + callbackUrl: string + codeVerifier?: string + }): Promise + publish(input: { + deliveryId: string + sourceUrl: string + callbackUrl: string + account: { + externalAccountId: string + accessToken: string + refreshToken?: string + } + destination: Record + }): Promise + revoke(input: { + externalAccountId: string + accessToken: string + refreshToken?: string + }): Promise +} + +export type PublishingAdapterRegistry = ReadonlyMap diff --git a/server/src/queue/client.ts b/server/src/queue/client.ts new file mode 100644 index 0000000..fed01c9 --- /dev/null +++ b/server/src/queue/client.ts @@ -0,0 +1,132 @@ +import { FlowProducer, Queue } from 'bullmq' +import { and, eq, gte, inArray, lt, sql } from 'drizzle-orm' +import { db } from '../db/client.js' +import { generationJobs, usageLedger, workspaces } from '../db/schema.js' +import { billingPeriod, estimateJobCredits } from '../lib/billing.js' +import { errors } from '../lib/errors.js' +import { publishRealtimeEvent } from '../lib/realtime.js' +import { assertJobProviderConfigured } from '../providers/readiness.js' +import { createRedisConnection } from './connection.js' +import { GENERATION_QUEUE, type GenerationJobData, type GenerationJobResult, type JobKind } from './types.js' + +const queueConnection = createRedisConnection() +const flowConnection = createRedisConnection() + +export const generationQueue = new Queue(GENERATION_QUEUE, { + connection: queueConnection, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 3000 }, + removeOnComplete: { age: 24 * 60 * 60, count: 1000 }, + removeOnFail: { age: 7 * 24 * 60 * 60, count: 5000 }, + }, +}) + +export const generationFlowProducer = new FlowProducer({ connection: flowConnection }) + +export async function enqueueGeneration(input: { + workspaceId: string + projectId?: string + episodeId?: string + userId: string + type: JobKind + idempotencyKey: string + payload: Record + maxAttempts?: number + delayMs?: number +}) { + const estimatedCredits = await estimateJobCredits(input.type, input.payload) + const persisted = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`billing:${input.workspaceId}`}))`) + const [existing] = await tx.select().from(generationJobs).where(and( + eq(generationJobs.workspaceId, input.workspaceId), + eq(generationJobs.idempotencyKey, input.idempotencyKey), + )).limit(1) + if (existing) return { record: existing, deduplicated: true } + + const [workspace] = await tx.select({ monthlyCreditLimit: workspaces.monthlyCreditLimit }).from(workspaces) + .where(eq(workspaces.id, input.workspaceId)).limit(1) + if (!workspace) throw errors.notFound('Workspace') + const { start, end } = billingPeriod() + const [[usage], [reservation]] = await Promise.all([ + tx.select({ credits: sql`coalesce(sum(${usageLedger.credits}), 0)` }).from(usageLedger).where(and( + eq(usageLedger.workspaceId, input.workspaceId), + gte(usageLedger.occurredAt, start), + lt(usageLedger.occurredAt, end), + )), + tx.select({ credits: sql`coalesce(sum(${generationJobs.estimatedCredits}), 0)` }).from(generationJobs).where(and( + eq(generationJobs.workspaceId, input.workspaceId), + inArray(generationJobs.status, ['QUEUED', 'RUNNING']), + )), + ]) + const usedCredits = Number(usage?.credits ?? 0) + const reservedCredits = Number(reservation?.credits ?? 0) + const remainingCredits = Math.max(0, workspace.monthlyCreditLimit - usedCredits - reservedCredits) + if (estimatedCredits > remainingCredits) { + throw errors.creditLimitExceeded({ + monthlyCreditLimit: workspace.monthlyCreditLimit, + usedCredits, + reservedCredits, + remainingCredits, + estimatedCredits, + }) + } + + assertJobProviderConfigured(input.type, input.payload) + + const [record] = await tx.insert(generationJobs).values({ + workspaceId: input.workspaceId, + projectId: input.projectId, + episodeId: input.episodeId, + createdById: input.userId, + type: input.type, + idempotencyKey: input.idempotencyKey, + input: input.payload, + maxAttempts: input.maxAttempts ?? 3, + estimatedCredits: estimatedCredits.toString(), + }).returning() + if (!record) throw new Error('Failed to persist generation job') + return { record, deduplicated: false } + }) + if (persisted.deduplicated) return { job: persisted.record, deduplicated: true } + const record = persisted.record + + try { + await generationQueue.add(input.type, { + databaseId: record.id, + workspaceId: input.workspaceId, + userId: input.userId, + type: input.type, + input: input.payload, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.episodeId ? { episodeId: input.episodeId } : {}), + }, { + jobId: record.id, + attempts: input.maxAttempts ?? 3, + ...(input.delayMs && input.delayMs > 0 ? { delay: input.delayMs } : {}), + }) + await publishRealtimeEvent({ + workspaceId: input.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: record.id, + payload: { id: record.id, type: input.type, status: 'QUEUED', progress: 0 }, + }) + } catch (error) { + await db.update(generationJobs).set({ + status: 'FAILED', + errorCode: 'QUEUE_UNAVAILABLE', + errorMessage: error instanceof Error ? error.message : 'Failed to enqueue job', + completedAt: new Date(), + updatedAt: new Date(), + }).where(eq(generationJobs.id, record.id)) + throw error + } + + return { job: record, deduplicated: false } +} + +export async function closeQueue() { + await Promise.all([generationQueue.close(), generationFlowProducer.close()]) + await Promise.all([queueConnection.quit(), flowConnection.quit()]) +} diff --git a/server/src/queue/connection.ts b/server/src/queue/connection.ts new file mode 100644 index 0000000..d12ca08 --- /dev/null +++ b/server/src/queue/connection.ts @@ -0,0 +1,17 @@ +import { Redis } from 'ioredis' +import { config } from '../config.js' + +export function createRedisConnection() { + return new Redis(config.REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: true, + lazyConnect: false, + }) +} + +export const redis = createRedisConnection() + +export async function checkRedis() { + const response = await redis.ping() + if (response !== 'PONG') throw new Error('Redis ping failed') +} diff --git a/server/src/queue/heartbeat.test.ts b/server/src/queue/heartbeat.test.ts new file mode 100644 index 0000000..811d478 --- /dev/null +++ b/server/src/queue/heartbeat.test.ts @@ -0,0 +1,34 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { createRedisConnection } from './connection.js' +import { publishWorkerHeartbeat, readWorkerHeartbeat } from './heartbeat.js' + +describe.sequential('worker heartbeat', () => { + const client = createRedisConnection() + const key = `frameflow:test:worker-heartbeat:${crypto.randomUUID()}` + + beforeAll(async () => { + await client.del(key) + }) + + afterAll(async () => { + await client.del(key) + await client.quit() + }) + + it('publishes a TTL-backed heartbeat and reports its age', async () => { + const now = new Date('2026-07-30T12:00:00.000Z') + await publishWorkerHeartbeat({ client, key, instanceId: 'worker-test-1', now, ttlSeconds: 60 }) + const heartbeat = await readWorkerHeartbeat({ client, key, now: new Date(now.getTime() + 4_000), maximumAgeMs: 10_000 }) + expect(heartbeat).toEqual({ instanceId: 'worker-test-1', updatedAt: now.toISOString(), ageMs: 4_000 }) + expect(await client.ttl(key)).toBeGreaterThan(0) + }) + + it('rejects stale or malformed heartbeat values', async () => { + const writtenAt = new Date('2026-07-30T12:00:00.000Z') + await publishWorkerHeartbeat({ client, key, instanceId: 'worker-test-2', now: writtenAt, ttlSeconds: 60 }) + expect(await readWorkerHeartbeat({ client, key, now: new Date(writtenAt.getTime() + 15_000), maximumAgeMs: 10_000 })).toBeNull() + + await client.set(key, 'not-json', 'EX', 60) + expect(await readWorkerHeartbeat({ client, key })).toBeNull() + }) +}) diff --git a/server/src/queue/heartbeat.ts b/server/src/queue/heartbeat.ts new file mode 100644 index 0000000..4270954 --- /dev/null +++ b/server/src/queue/heartbeat.ts @@ -0,0 +1,56 @@ +import type { Redis } from 'ioredis' +import { config } from '../config.js' +import { redis } from './connection.js' + +export interface WorkerHeartbeat { + instanceId: string + updatedAt: string +} + +export async function publishWorkerHeartbeat(input: { + client?: Redis + key?: string + instanceId: string + now?: Date + ttlSeconds?: number +}) { + const client = input.client ?? redis + const now = input.now ?? new Date() + const heartbeat: WorkerHeartbeat = { instanceId: input.instanceId, updatedAt: now.toISOString() } + await client.set( + input.key ?? config.WORKER_HEARTBEAT_KEY, + JSON.stringify(heartbeat), + 'EX', + input.ttlSeconds ?? config.WORKER_HEARTBEAT_TTL_SECONDS, + ) + return heartbeat +} + +export async function readWorkerHeartbeat(input: { + client?: Redis + key?: string + now?: Date + maximumAgeMs?: number +} = {}) { + const client = input.client ?? redis + const value = await client.get(input.key ?? config.WORKER_HEARTBEAT_KEY) + if (!value) return null + let heartbeat: WorkerHeartbeat + try { + heartbeat = JSON.parse(value) as WorkerHeartbeat + } catch { + return null + } + const updatedAtMs = Date.parse(heartbeat.updatedAt) + if (!heartbeat.instanceId || !Number.isFinite(updatedAtMs)) return null + const ageMs = Math.max(0, (input.now ?? new Date()).getTime() - updatedAtMs) + const maximumAgeMs = input.maximumAgeMs ?? config.WORKER_HEARTBEAT_TTL_SECONDS * 1000 + if (ageMs > maximumAgeMs) return null + return { ...heartbeat, ageMs } +} + +export async function checkWorkerHeartbeat() { + const heartbeat = await readWorkerHeartbeat() + if (!heartbeat) throw new Error('Generation worker heartbeat is missing or stale') + return heartbeat +} diff --git a/server/src/queue/processors.ts b/server/src/queue/processors.ts new file mode 100644 index 0000000..8753d59 --- /dev/null +++ b/server/src/queue/processors.ts @@ -0,0 +1,1004 @@ +import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm' +import { z } from 'zod' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { + assets, + assetVersions, + deliveries, + episodes, + generationJobs, + publishingChannelAccounts, + projects, + renders, + scriptVersions, + shots, + shotVersions, +} from '../db/schema.js' +import type { RenderSourceManifest } from '../db/schema.js' +import { errors } from '../lib/errors.js' +import { decryptCredential, encryptCredential } from '../lib/credential-vault.js' +import { downloadRemoteMedia } from '../lib/remote-media.js' +import { + applyCharacterConsistency, + readCharacterConsistencyProfile, + type AppliedCharacterProfile, +} from '../lib/character-consistency.js' +import { buildSubtitleDocument, composeEpisode, episodeRenderSettingsSchema, shotProductionSettingsSchema } from '../media/ffmpeg.js' +import { imageProvider, speechProvider, textProvider, videoProvider } from '../providers/index.js' +import type { BinaryGenerationResult } from '../providers/types.js' +import { createPublishingAdapterRegistry, publishingConnectorCallbackUrl } from '../publishing/connector.js' +import { externalPublishingPlatforms, type PublishingAdapterRegistry } from '../publishing/types.js' +import { publishRealtimeEvent } from '../lib/realtime.js' +import { + generatedObjectKey, + getObjectBuffer, + presignDownload, + putObject, +} from '../storage/client.js' +import type { GenerationJobData, GenerationJobResult } from './types.js' + +const scriptInputSchema = z.object({ + instruction: z.string().trim().min(1).max(20_000).default('根据项目设定和本集梗概生成完整分场剧本'), + provider: z.enum(['openai', 'agnes', 'generic']).default(config.AGNES_API_KEY ? 'agnes' : 'openai'), + includeExistingScript: z.boolean().default(true), +}) + +const storyboardInputSchema = z.object({ + instruction: z.string().trim().min(1).max(20_000).default('根据当前剧本生成完整、可制作的漫剧分镜'), + provider: z.enum(['openai', 'agnes', 'generic']).default(config.AGNES_API_KEY ? 'agnes' : 'openai'), + replaceExisting: z.boolean().default(false), +}) + +const assetInputSchema = z.object({ + assetId: z.uuid().optional(), + shotId: z.uuid().optional(), + prompt: z.string().trim().min(1).max(32_000), + negativePrompt: z.string().max(10_000).default(''), + width: z.number().int().min(256).max(4096).default(1024), + height: z.number().int().min(256).max(4096).default(1024), + referenceAssetVersionIds: z.array(z.uuid()).max(16).default([]), + provider: z.enum(['openai', 'replicate', 'agnes', 'generic']).default(config.AGNES_API_KEY ? 'agnes' : 'openai'), + params: z.record(z.string(), z.unknown()).default({}), +}).refine((input) => Boolean(input.assetId) !== Boolean(input.shotId), { + message: 'Exactly one of assetId or shotId is required', +}) + +const speechInputSchema = z.object({ + shotId: z.uuid(), + text: z.string().trim().min(1).max(4096), + voice: z.string().trim().min(1).max(80).optional(), + instructions: z.string().max(2000).optional(), + speed: z.number().min(0.25).max(4).optional(), + provider: z.enum(['openai']).default('openai'), +}) + +const videoInputSchema = z.object({ + shotId: z.uuid(), + prompt: z.string().trim().min(1).max(20_000), + imageAssetVersionId: z.uuid().optional(), + durationSeconds: z.number().min(1).max(30).optional(), + provider: z.enum(['replicate', 'agnes', 'generic']).default(config.AGNES_API_KEY ? 'agnes' : 'replicate'), + params: z.record(z.string(), z.unknown()).default({}), +}) + +const lipsyncInputSchema = z.object({ + shotId: z.uuid(), + videoStorageKey: z.string().min(1).max(1000).optional(), + audioStorageKey: z.string().min(1).max(1000).optional(), + provider: z.enum(['replicate']).default('replicate'), + params: z.record(z.string(), z.unknown()).default({}), +}) + +const renderInputSchema = z.object({ + renderId: z.uuid(), + prerequisiteJobIds: z.array(z.uuid()).max(2000).default([]), +}) +const deliveryInputSchema = z.object({ deliveryId: z.uuid() }) + +export class JobCancelledError extends Error { + constructor() { + super('Generation job was cancelled') + this.name = 'JobCancelledError' + } +} + +function parseInput(schema: z.ZodType, input: Record): T { + const parsed = schema.safeParse(input) + if (!parsed.success) throw errors.badRequest('Invalid generation job input', parsed.error.issues) + return parsed.data +} + +function requireScopeId(value: string | undefined, name: 'projectId' | 'episodeId') { + if (!value) throw errors.badRequest(`${name} is required for this job type`) + return value +} + +async function ensureNotCancelled(data: GenerationJobData) { + const [record] = await db.select({ status: generationJobs.status }).from(generationJobs) + .where(eq(generationJobs.id, data.databaseId)).limit(1) + if (!record || record.status === 'CANCELLED') throw new JobCancelledError() +} + +function assertScope(data: GenerationJobData, row: { workspaceId: string; projectId: string; episodeId?: string }) { + if (row.workspaceId !== data.workspaceId) throw errors.forbidden('Job workspace does not own this resource') + if (data.projectId && row.projectId !== data.projectId) throw errors.badRequest('Job project does not match the target resource') + if (data.episodeId && row.episodeId && row.episodeId !== data.episodeId) throw errors.badRequest('Job episode does not match the target resource') +} + +async function episodeContext(episodeId: string) { + const [row] = await db.select({ episode: episodes, project: projects }).from(episodes) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(episodes.id, episodeId)).limit(1) + if (!row) throw errors.notFound('Episode') + return row +} + +async function assetContext(assetId: string) { + const [row] = await db.select({ asset: assets, project: projects }).from(assets) + .innerJoin(projects, eq(projects.id, assets.projectId)) + .where(eq(assets.id, assetId)).limit(1) + if (!row) throw errors.notFound('Asset') + return row +} + +async function shotContext(shotId: string) { + const [row] = await db.select({ shot: shots, episode: episodes, project: projects }).from(shots) + .innerJoin(episodes, eq(episodes.id, shots.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(shots.id, shotId)).limit(1) + if (!row) throw errors.notFound('Shot') + return row +} + +function extensionForMimeType(mimeType: string) { + const extensions: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/flac': 'flac', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + } + const extension = extensions[mimeType] + if (!extension) throw errors.badRequest(`Unsupported generated media type: ${mimeType}`) + return extension +} + +async function materializeBinary(result: BinaryGenerationResult) { + const body = result.data + ? result.data + : result.url + ? await downloadRemoteMedia(result.url, { + maxBytes: config.MAX_UPLOAD_BYTES, + timeoutMs: config.PROVIDER_DOWNLOAD_TIMEOUT_MS, + maxRedirects: config.PROVIDER_DOWNLOAD_MAX_REDIRECTS, + }) + : null + if (!body) throw new Error('Provider returned neither binary data nor an output URL') + if (body.length > config.MAX_UPLOAD_BYTES) throw errors.badRequest('Generated media exceeds the configured storage limit') + if (result.mimeType === 'video/mp4' && (body.length < 12 || body.subarray(4, 8).toString('ascii') !== 'ftyp')) { + throw errors.badRequest('Provider returned invalid MP4 video content') + } + return body +} + +async function storeGeneratedMedia(input: { + data: GenerationJobData + result: BinaryGenerationResult + category: string +}) { + const projectId = requireScopeId(input.data.projectId, 'projectId') + const body = await materializeBinary(input.result) + const key = generatedObjectKey(input.data.workspaceId, projectId, input.category, extensionForMimeType(input.result.mimeType)) + await putObject({ + key, + body, + contentType: input.result.mimeType, + metadata: { projectId, jobId: input.data.databaseId, provider: input.result.model }, + }) + return { key, byteSize: body.length } +} + +async function createShotMediaVersion(input: { + data: GenerationJobData + shotId: string + generationParams: Record + imageAssetVersionId?: string | null + videoStorageKey?: string | null + audioStorageKey?: string | null + subtitleText?: string +}) { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${input.shotId}`}))`) + const [latest] = await tx.select().from(shotVersions).where(eq(shotVersions.shotId, input.shotId)) + .orderBy(desc(shotVersions.version)).limit(1) + const [current] = await tx.select().from(shotVersions).where(and( + eq(shotVersions.shotId, input.shotId), + eq(shotVersions.isCurrent, true), + )).limit(1) + const base = current ?? latest + await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, input.shotId)) + const [created] = await tx.insert(shotVersions).values({ + shotId: input.shotId, + version: (latest?.version ?? 0) + 1, + imageAssetVersionId: input.imageAssetVersionId !== undefined ? input.imageAssetVersionId : base?.imageAssetVersionId, + videoStorageKey: input.videoStorageKey !== undefined ? input.videoStorageKey : base?.videoStorageKey, + audioStorageKey: input.audioStorageKey !== undefined ? input.audioStorageKey : base?.audioStorageKey, + subtitleText: input.subtitleText ?? base?.subtitleText ?? '', + generationParams: { ...(base?.generationParams ?? {}), ...input.generationParams, jobId: input.data.databaseId }, + isCurrent: true, + createdById: input.data.userId, + }).returning() + if (!created) throw new Error('Failed to persist generated shot version') + await tx.update(shots).set({ status: 'REVIEW', updatedAt: new Date() }).where(eq(shots.id, input.shotId)) + return created + }) +} + +async function processScript(data: GenerationJobData): Promise { + const episodeId = requireScopeId(data.episodeId, 'episodeId') + const input = parseInput(scriptInputSchema, data.input) + const row = await episodeContext(episodeId) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId }) + const [current] = input.includeExistingScript + ? await db.select().from(scriptVersions).where(and(eq(scriptVersions.episodeId, episodeId), eq(scriptVersions.isCurrent, true))).limit(1) + : [] + const provider = textProvider(input.provider) + const result = await provider.generateScript({ + instruction: input.instruction, + projectName: row.project.name, + genre: row.project.genre, + visualBible: row.project.visualBible, + episodeTitle: row.episode.title, + synopsis: row.episode.synopsis, + targetDurationSeconds: row.episode.targetDurationSeconds, + safetyIdentifier: data.userId, + ...(current?.plainText ? { existingScript: current.plainText } : {}), + }) + await ensureNotCancelled(data) + + const version = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`script:${episodeId}`}))`) + const [latest] = await tx.select({ version: scriptVersions.version }).from(scriptVersions) + .where(eq(scriptVersions.episodeId, episodeId)).orderBy(desc(scriptVersions.version)).limit(1) + await tx.update(scriptVersions).set({ isCurrent: false }).where(eq(scriptVersions.episodeId, episodeId)) + const [created] = await tx.insert(scriptVersions).values({ + episodeId, + version: (latest?.version ?? 0) + 1, + title: result.document.title, + content: result.document as unknown as Record, + plainText: result.plainText, + changeSummary: current ? 'AI 改写生成' : 'AI 初稿生成', + isCurrent: true, + createdById: data.userId, + }).returning() + if (!created) throw new Error('Failed to persist generated script') + await tx.update(episodes).set({ status: 'SCRIPTING', updatedAt: new Date() }).where(eq(episodes.id, episodeId)) + return created + }) + + return { + provider: provider.name, + providerModel: result.model, + output: { episodeId, scriptVersionId: version.id, version: version.version, title: version.title }, + usage: result.usage, + } +} + +function matchAssetId(name: string | null, candidates: Array<{ id: string; name: string }>) { + if (!name) return null + const normalized = name.trim().toLocaleLowerCase('zh-CN') + return candidates.find((candidate) => { + const candidateName = candidate.name.trim().toLocaleLowerCase('zh-CN') + return candidateName === normalized || candidateName.includes(normalized) || normalized.includes(candidateName) + })?.id ?? null +} + +async function processStoryboard(data: GenerationJobData): Promise { + const episodeId = requireScopeId(data.episodeId, 'episodeId') + const input = parseInput(storyboardInputSchema, data.input) + const row = await episodeContext(episodeId) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId }) + + const [currentScript] = await db.select().from(scriptVersions) + .where(and(eq(scriptVersions.episodeId, episodeId), eq(scriptVersions.isCurrent, true))).limit(1) + if (!currentScript?.plainText) throw errors.badRequest('A saved script is required before generating a storyboard') + + const existingShots = await db.select({ id: shots.id, status: shots.status }).from(shots) + .where(eq(shots.episodeId, episodeId)) + if (existingShots.length > 0 && !input.replaceExisting) { + throw errors.conflict('This episode already has shots; confirm replacement to regenerate the storyboard') + } + if (input.replaceExisting && existingShots.some((shot) => shot.status !== 'DRAFT')) { + throw errors.conflict('Only a draft-only storyboard can be replaced; archive produced shots manually first') + } + + const projectAssets = await db.select({ id: assets.id, name: assets.name, type: assets.type }).from(assets) + .where(eq(assets.projectId, row.project.id)) + const characterAssets = projectAssets.filter((asset) => asset.type === 'CHARACTER') + const sceneAssets = projectAssets.filter((asset) => asset.type === 'SCENE') + const provider = textProvider(input.provider) + const result = await provider.generateStoryboard({ + projectName: row.project.name, + genre: row.project.genre, + visualBible: row.project.visualBible, + episodeTitle: row.episode.title, + targetDurationSeconds: row.episode.targetDurationSeconds, + script: currentScript.plainText, + instruction: input.instruction, + characterNames: characterAssets.map((asset) => asset.name), + sceneNames: sceneAssets.map((asset) => asset.name), + safetyIdentifier: data.userId, + }) + await ensureNotCancelled(data) + + const storyboardShots = z.array(z.object({ + sceneNumber: z.number().int().min(1), + title: z.string().trim().min(1).max(180), + description: z.string().trim().min(1).max(10_000), + shotType: z.string().trim().min(1).max(50), + cameraMotion: z.string().trim().min(1).max(50), + durationMs: z.number().int().min(500).max(30_000), + characterNames: z.array(z.string().trim().min(1)).max(20), + sceneName: z.string().trim().min(1).nullable(), + subtitleText: z.string().max(10_000), + })).min(1).max(300).parse(result.shots) + + const created = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`storyboard:${episodeId}`}))`) + if (input.replaceExisting && existingShots.length > 0) { + await tx.delete(shots).where(eq(shots.episodeId, episodeId)) + } + const createdShots = await tx.insert(shots).values(storyboardShots.map((shot, index) => ({ + episodeId, + shotNumber: index + 1, + sceneNumber: shot.sceneNumber, + title: shot.title, + description: shot.description, + shotType: shot.shotType, + cameraMotion: shot.cameraMotion, + durationMs: shot.durationMs, + characterAssetIds: [...new Set(shot.characterNames.map((name) => matchAssetId(name, characterAssets)).filter((id): id is string => Boolean(id)))], + sceneAssetId: matchAssetId(shot.sceneName, sceneAssets), + sortOrder: (index + 1) * 100, + status: 'DRAFT' as const, + createdById: data.userId, + }))).returning() + const versionRows = createdShots.map((shot, index) => ({ + shotId: shot.id, + version: 1, + subtitleText: storyboardShots[index]?.subtitleText ?? '', + generationParams: { operation: 'storyboard', scriptVersionId: currentScript.id, jobId: data.databaseId }, + isCurrent: true, + createdById: data.userId, + })) + if (versionRows.length > 0) await tx.insert(shotVersions).values(versionRows) + await tx.update(episodes).set({ status: 'STORYBOARDING', updatedAt: new Date() }).where(eq(episodes.id, episodeId)) + return createdShots + }) + + return { + provider: provider.name, + providerModel: result.model, + output: { episodeId, scriptVersionId: currentScript.id, shotCount: created.length, shotIds: created.map((shot) => shot.id) }, + usage: result.usage, + } +} + +async function processAsset(data: GenerationJobData): Promise { + const projectId = requireScopeId(data.projectId, 'projectId') + const input = parseInput(assetInputSchema, data.input) + let targetAsset: typeof assets.$inferSelect + let targetShot: typeof shots.$inferSelect | undefined + if (input.assetId) { + const row = await assetContext(input.assetId) + targetAsset = row.asset + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id }) + } else { + const row = await shotContext(input.shotId!) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + targetShot = row.shot + const [created] = await db.insert(assets).values({ + projectId: row.project.id, + type: 'SHOT', + name: `镜头 ${String(row.shot.shotNumber).padStart(2, '0')} · ${row.shot.title}`, + description: input.prompt, + status: 'GENERATING', + tags: ['镜头画面'], + metadata: { shotId: row.shot.id }, + createdById: data.userId, + }).returning() + if (!created) throw new Error('Failed to create shot image asset') + targetAsset = created + } + try { + await db.update(assets).set({ status: 'GENERATING', updatedAt: new Date() }).where(eq(assets.id, targetAsset.id)) + + const appliedProfiles: AppliedCharacterProfile[] = [] + let otherAutomaticReferenceIds: string[] = [] + if (targetAsset.type === 'CHARACTER') { + const profile = readCharacterConsistencyProfile(targetAsset.metadata) + if (profile?.identityLocked) appliedProfiles.push({ ...profile, assetId: targetAsset.id, assetName: targetAsset.name }) + } + if (targetShot) { + const referenceAssetIds = [...targetShot.characterAssetIds, ...(targetShot.sceneAssetId ? [targetShot.sceneAssetId] : [])] + if (referenceAssetIds.length > 0) { + const referenceAssets = await db.select({ + id: assets.id, + name: assets.name, + type: assets.type, + metadata: assets.metadata, + }).from(assets).where(and(eq(assets.projectId, projectId), inArray(assets.id, referenceAssetIds))) + const currentAssetIds: string[] = [] + for (const referenceAsset of referenceAssets) { + const profile = referenceAsset.type === 'CHARACTER' + ? readCharacterConsistencyProfile(referenceAsset.metadata) + : null + if (profile?.identityLocked) { + appliedProfiles.push({ ...profile, assetId: referenceAsset.id, assetName: referenceAsset.name }) + } else { + currentAssetIds.push(referenceAsset.id) + } + } + if (currentAssetIds.length > 0) { + const currentReferences = await db.select({ id: assetVersions.id }).from(assetVersions) + .where(and(inArray(assetVersions.assetId, currentAssetIds), eq(assetVersions.isCurrent, true))) + otherAutomaticReferenceIds = currentReferences.map((reference) => reference.id) + } + } + } + const consistency = applyCharacterConsistency({ + prompt: input.prompt, + negativePrompt: input.negativePrompt, + params: input.params, + manualReferenceAssetVersionIds: input.referenceAssetVersionIds, + profiles: appliedProfiles, + otherAutomaticReferenceAssetVersionIds: otherAutomaticReferenceIds, + }) + const referenceRows = consistency.referenceAssetVersionIds.length > 0 + ? await db.select({ + id: assetVersions.id, + storageKey: assetVersions.storageKey, + mimeType: assetVersions.mimeType, + assetName: assets.name, + }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .where(and(inArray(assetVersions.id, consistency.referenceAssetVersionIds), eq(assets.projectId, projectId))) + : [] + if (referenceRows.length !== consistency.referenceAssetVersionIds.length || referenceRows.some((reference) => !reference.storageKey || !reference.mimeType?.startsWith('image/'))) { + throw errors.badRequest('Every reference must be a stored image asset version from the same project') + } + const referenceById = new Map(referenceRows.map((reference) => [reference.id, reference])) + const orderedReferenceRows = consistency.referenceAssetVersionIds.map((id) => referenceById.get(id)!) + const references = await Promise.all(orderedReferenceRows.map(async (reference) => ({ + name: `${reference.assetName}-${reference.id}`, + mimeType: reference.mimeType!, + data: await getObjectBuffer(reference.storageKey!), + }))) + const referenceUrls = await Promise.all(orderedReferenceRows.map((reference) => presignDownload(reference.storageKey!, 3600))) + + const provider = imageProvider(input.provider) + const result = await provider.generateImage({ + prompt: consistency.prompt, + negativePrompt: consistency.negativePrompt, + width: input.width, + height: input.height, + references, + referenceUrls, + params: consistency.params, + safetyIdentifier: data.userId, + }) + await ensureNotCancelled(data) + const stored = await storeGeneratedMedia({ data, result, category: `assets/${targetAsset.type.toLowerCase()}` }) + + const version = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`asset:${targetAsset.id}`}))`) + const [latest] = await tx.select({ version: assetVersions.version }).from(assetVersions) + .where(eq(assetVersions.assetId, targetAsset.id)).orderBy(desc(assetVersions.version)).limit(1) + await tx.update(assetVersions).set({ isCurrent: false }).where(eq(assetVersions.assetId, targetAsset.id)) + const [created] = await tx.insert(assetVersions).values({ + assetId: targetAsset.id, + version: (latest?.version ?? 0) + 1, + storageKey: stored.key, + mimeType: result.mimeType, + byteSize: stored.byteSize, + width: result.width ?? input.width, + height: result.height ?? input.height, + prompt: consistency.prompt, + negativePrompt: consistency.negativePrompt, + provider: provider.name, + providerModel: result.model, + seed: result.seed, + generationParams: { + ...consistency.params, + referenceAssetVersionIds: consistency.referenceAssetVersionIds, + appliedCharacterConsistencyProfiles: consistency.profileSnapshots, + requestedPrompt: input.prompt, + requestedNegativePrompt: input.negativePrompt, + externalId: result.externalId, + ...(result.sourceUrl ? { providerSourceUrl: result.sourceUrl } : {}), + jobId: data.databaseId, + }, + isCurrent: true, + createdById: data.userId, + }).returning() + if (!created) throw new Error('Failed to persist generated asset version') + await tx.update(assets).set({ status: 'REVIEW', updatedAt: new Date() }).where(eq(assets.id, targetAsset.id)) + return created + }) + if (targetShot) { + await createShotMediaVersion({ + data, + shotId: targetShot.id, + imageAssetVersionId: version.id, + generationParams: { + operation: 'storyboard-image', + assetId: targetAsset.id, + sourceAssetVersionId: version.id, + provider: provider.name, + model: result.model, + appliedCharacterConsistencyProfiles: consistency.profileSnapshots, + }, + }) + } + + return { + provider: provider.name, + providerModel: result.model, + output: { + assetId: targetAsset.id, + assetVersionId: version.id, + version: version.version, + storageKey: stored.key, + ...(targetShot ? { shotId: targetShot.id } : {}), + }, + usage: result.usage, + } + } catch (error) { + await db.update(assets).set({ status: 'DRAFT', updatedAt: new Date() }).where(eq(assets.id, targetAsset.id)) + throw error + } +} + +async function processSpeech(data: GenerationJobData): Promise { + const input = parseInput(speechInputSchema, data.input) + const row = await shotContext(input.shotId) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + const provider = speechProvider(input.provider) + const result = await provider.generateSpeech({ + text: input.text, + safetyIdentifier: data.userId, + ...(input.voice ? { voice: input.voice } : {}), + ...(input.instructions ? { instructions: input.instructions } : {}), + ...(input.speed !== undefined ? { speed: input.speed } : {}), + }) + await ensureNotCancelled(data) + const stored = await storeGeneratedMedia({ data, result, category: 'shots/audio' }) + const version = await createShotMediaVersion({ + data, + shotId: row.shot.id, + audioStorageKey: stored.key, + subtitleText: input.text, + generationParams: { operation: 'tts', voice: input.voice, speed: input.speed, provider: provider.name, model: result.model }, + }) + return { + provider: provider.name, + providerModel: result.model, + output: { shotId: row.shot.id, shotVersionId: version.id, version: version.version, audioStorageKey: stored.key }, + usage: result.usage, + } +} + +async function processVideo(data: GenerationJobData): Promise { + const input = parseInput(videoInputSchema, data.input) + const row = await shotContext(input.shotId) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + const [currentShotVersion] = await db.select().from(shotVersions) + .where(and(eq(shotVersions.shotId, row.shot.id), eq(shotVersions.isCurrent, true))).limit(1) + const imageAssetVersionId = input.imageAssetVersionId ?? currentShotVersion?.imageAssetVersionId + if (!imageAssetVersionId) throw errors.badRequest('A source image asset version is required for video generation') + const [sourceImage] = await db.select({ + storageKey: assetVersions.storageKey, + mimeType: assetVersions.mimeType, + width: assetVersions.width, + height: assetVersions.height, + generationParams: assetVersions.generationParams, + }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .where(and(eq(assetVersions.id, imageAssetVersionId), eq(assets.projectId, row.project.id))).limit(1) + if (!sourceImage?.storageKey) throw errors.badRequest('The source image must be stored in this project') + + const durationSeconds = input.durationSeconds ?? row.shot.durationMs / 1000 + const provider = videoProvider(input.provider) + const providerSourceUrl = typeof sourceImage.generationParams?.providerSourceUrl === 'string' + ? sourceImage.generationParams.providerSourceUrl + : null + const result = await provider.generateVideo({ + prompt: input.prompt, + imageUrl: providerSourceUrl ?? await presignDownload(sourceImage.storageKey, 3600), + imageData: await getObjectBuffer(sourceImage.storageKey), + imageMimeType: sourceImage.mimeType ?? 'image/png', + ...(sourceImage.width ? { sourceWidth: sourceImage.width } : {}), + ...(sourceImage.height ? { sourceHeight: sourceImage.height } : {}), + durationSeconds, + params: input.params, + }) + await ensureNotCancelled(data) + const stored = await storeGeneratedMedia({ data, result, category: 'shots/video' }) + const version = await createShotMediaVersion({ + data, + shotId: row.shot.id, + imageAssetVersionId, + videoStorageKey: stored.key, + generationParams: { operation: 'video', prompt: input.prompt, durationSeconds, provider: provider.name, model: result.model, externalId: result.externalId }, + }) + return { + provider: provider.name, + providerModel: result.model, + output: { shotId: row.shot.id, shotVersionId: version.id, version: version.version, videoStorageKey: stored.key }, + usage: result.usage, + } +} + +async function processLipsync(data: GenerationJobData): Promise { + const input = parseInput(lipsyncInputSchema, data.input) + const row = await shotContext(input.shotId) + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + const [current] = await db.select().from(shotVersions) + .where(and(eq(shotVersions.shotId, row.shot.id), eq(shotVersions.isCurrent, true))).limit(1) + const videoStorageKey = input.videoStorageKey ?? current?.videoStorageKey + const audioStorageKey = input.audioStorageKey ?? current?.audioStorageKey + if (!videoStorageKey || !audioStorageKey) throw errors.badRequest('Stored video and audio are required for lip-sync generation') + const projectPrefix = `${data.workspaceId}/${row.project.id}/` + if (!videoStorageKey.startsWith(projectPrefix) || !audioStorageKey.startsWith(projectPrefix)) { + throw errors.forbidden('Lip-sync media must belong to the job project') + } + + const provider = videoProvider(input.provider) + const result = await provider.generateLipsync({ + videoUrl: await presignDownload(videoStorageKey, 3600), + audioUrl: await presignDownload(audioStorageKey, 3600), + params: input.params, + }) + await ensureNotCancelled(data) + const stored = await storeGeneratedMedia({ data, result, category: 'shots/lipsync' }) + const version = await createShotMediaVersion({ + data, + shotId: row.shot.id, + videoStorageKey: stored.key, + audioStorageKey, + generationParams: { operation: 'lipsync', provider: provider.name, model: result.model, externalId: result.externalId }, + }) + return { + provider: provider.name, + providerModel: result.model, + output: { shotId: row.shot.id, shotVersionId: version.id, version: version.version, videoStorageKey: stored.key }, + usage: result.usage, + } +} + +async function processRender(data: GenerationJobData): Promise { + const input = parseInput(renderInputSchema, data.input) + const [row] = await db.select({ render: renders, episode: episodes, project: projects }).from(renders) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(renders.id, input.renderId)).limit(1) + if (!row) throw errors.notFound('Render') + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + const settings = parseInput(episodeRenderSettingsSchema, row.render.settings) + const projectPrefix = `${data.workspaceId}/${row.project.id}/` + + await db.update(renders).set({ status: 'RENDERING', updatedAt: new Date(), qualityReport: null }) + .where(eq(renders.id, row.render.id)) + + try { + if (input.prerequisiteJobIds.length > 0) { + const prerequisites = await db.select({ + id: generationJobs.id, + type: generationJobs.type, + status: generationJobs.status, + errorMessage: generationJobs.errorMessage, + }).from(generationJobs).where(inArray(generationJobs.id, input.prerequisiteJobIds)) + const failed = prerequisites.filter((job) => job.status !== 'SUCCEEDED') + if (prerequisites.length !== input.prerequisiteJobIds.length || failed.length > 0) { + const failedSummary = failed.slice(0, 5).map((job) => `${job.type}: ${job.errorMessage ?? job.status}`).join('; ') + throw errors.conflict(`Production prerequisites failed${failedSummary ? `: ${failedSummary}` : ''}`) + } + } + + const shotRows = await db.select({ shot: shots, version: shotVersions, image: assetVersions }).from(shots) + .leftJoin(shotVersions, and(eq(shotVersions.shotId, shots.id), eq(shotVersions.isCurrent, true))) + .leftJoin(assetVersions, eq(assetVersions.id, shotVersions.imageAssetVersionId)) + .where(eq(shots.episodeId, row.episode.id)).orderBy(asc(shots.sortOrder)) + if (shotRows.length === 0) throw errors.badRequest('The episode has no shots to render') + + const clips = await Promise.all(shotRows.map(async ({ shot, version, image }, index) => { + if (!version) throw errors.badRequest(`Shot ${shot.shotNumber} has no current media version`) + const visualKey = version.videoStorageKey ?? image?.storageKey + if (!visualKey) throw errors.badRequest(`Shot ${shot.shotNumber} has no video or image source`) + if (!visualKey.startsWith(projectPrefix) || (version.audioStorageKey && !version.audioStorageKey.startsWith(projectPrefix))) { + throw errors.forbidden(`Shot ${shot.shotNumber} references media outside this project`) + } + const visual = await getObjectBuffer(visualKey) + const audio = version.audioStorageKey ? await getObjectBuffer(version.audioStorageKey) : undefined + const production = shotProductionSettingsSchema.parse(version.generationParams?.production ?? {}) + return { + durationMs: shot.durationMs, + subtitleText: version.subtitleText, + production, + ...(version.videoStorageKey + ? { video: { data: visual, extension: 'mp4' } } + : { image: { data: visual, extension: extensionForMimeType(image?.mimeType ?? 'image/png') } }), + ...(audio ? { audio: { data: audio, extension: 'mp3' } } : {}), + sortOrder: index, + } + })) + const sourceManifest = { + schemaVersion: 1, + capturedAt: new Date().toISOString(), + shots: shotRows.map(({ shot, version }) => { + if (!version) throw errors.badRequest(`Shot ${shot.shotNumber} has no current media version`) + return { + shotId: shot.id, + shotVersionId: version.id, + shotNumber: shot.shotNumber, + shotVersion: version.version, + sortOrder: shot.sortOrder, + durationMs: shot.durationMs, + subtitleText: version.subtitleText, + imageAssetVersionId: version.imageAssetVersionId, + } + }), + } satisfies RenderSourceManifest + + let backgroundMusic: { data: Buffer; extension: string } | undefined + if (settings.backgroundMusicStorageKey) { + if (!settings.backgroundMusicStorageKey.startsWith(projectPrefix)) throw errors.forbidden('Background music must belong to the job project') + backgroundMusic = { data: await getObjectBuffer(settings.backgroundMusicStorageKey), extension: 'mp3' } + } + + const composition = await composeEpisode({ + clips, + settings, + ...(backgroundMusic ? { backgroundMusic } : {}), + }) + await ensureNotCancelled(data) + const storageKey = generatedObjectKey(data.workspaceId, row.project.id, 'renders', 'mp4') + await putObject({ + key: storageKey, + body: composition.data, + contentType: 'video/mp4', + metadata: { projectId: row.project.id, episodeId: row.episode.id, renderId: row.render.id, jobId: data.databaseId }, + }) + const subtitleSrt = buildSubtitleDocument(clips, 'srt') + const subtitleVtt = buildSubtitleDocument(clips, 'vtt') + const subtitleSrtKey = subtitleSrt ? generatedObjectKey(data.workspaceId, row.project.id, 'renders-subtitles', 'srt') : null + const subtitleVttKey = subtitleVtt ? generatedObjectKey(data.workspaceId, row.project.id, 'renders-subtitles', 'vtt') : null + await Promise.all([ + ...(subtitleSrtKey ? [putObject({ + key: subtitleSrtKey, + body: subtitleSrt, + contentType: 'application/x-subrip; charset=utf-8', + metadata: { projectId: row.project.id, episodeId: row.episode.id, renderId: row.render.id, jobId: data.databaseId, format: 'srt' }, + })] : []), + ...(subtitleVttKey ? [putObject({ + key: subtitleVttKey, + body: subtitleVtt, + contentType: 'text/vtt; charset=utf-8', + metadata: { projectId: row.project.id, episodeId: row.episode.id, renderId: row.render.id, jobId: data.databaseId, format: 'vtt' }, + })] : []), + ]) + const subtitleFormats = [ + ...(subtitleSrtKey ? ['srt' as const] : []), + ...(subtitleVttKey ? ['vtt' as const] : []), + ] + const qualityReport = { + shotCount: shotRows.length, + missingAudioShotCount: shotRows.filter(({ version }) => !version?.audioStorageKey).length, + subtitlesBurned: composition.subtitlesMode === 'burned', + subtitlesMode: composition.subtitlesMode, + subtitleFormats, + backgroundMusicMixed: Boolean(backgroundMusic), + audioNormalized: settings.normalizeAudio, + editedShotCount: clips.filter((clip) => { + const production = clip.production! + return production.trimStartMs > 0 + || production.playbackRate !== 1 + || production.audioVolume !== 1 + || production.fit !== 'contain' + || production.transition !== 'cut' + }).length, + codec: 'h264/aac', + } + await db.transaction(async (tx) => { + await tx.update(renders).set({ + status: 'REVIEW', + storageKey, + mimeType: 'video/mp4', + byteSize: composition.data.length, + durationMs: composition.durationMs, + subtitleSrtKey, + subtitleVttKey, + sourceManifest, + qualityReport, + updatedAt: new Date(), + }).where(eq(renders.id, row.render.id)) + await tx.update(shots).set({ status: 'RENDERED', updatedAt: new Date() }) + .where(inArray(shots.id, shotRows.map(({ shot }) => shot.id))) + await tx.update(episodes).set({ status: 'REVIEW', updatedAt: new Date() }).where(eq(episodes.id, row.episode.id)) + }) + return { + provider: 'local', + providerModel: 'ffmpeg-h264-aac-v1', + output: { renderId: row.render.id, storageKey, durationMs: composition.durationMs, subtitleFormats, sourceShotCount: sourceManifest.shots.length, qualityReport }, + usage: [{ kind: 'RENDER_SECOND', quantity: composition.durationMs / 1000, credits: composition.durationMs / 1000 * 0.8 }], + } + } catch (error) { + await db.update(renders).set({ + status: 'FAILED', + qualityReport: { error: error instanceof Error ? error.message : 'Render failed' }, + updatedAt: new Date(), + }).where(eq(renders.id, row.render.id)) + throw error + } +} + +async function processDelivery(data: GenerationJobData, publishingAdapters: PublishingAdapterRegistry): Promise { + const input = parseInput(deliveryInputSchema, data.input) + const [row] = await db.select({ delivery: deliveries, render: renders, episode: episodes, project: projects, channelAccount: publishingChannelAccounts }).from(deliveries) + .innerJoin(renders, eq(renders.id, deliveries.renderId)) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .leftJoin(publishingChannelAccounts, eq(publishingChannelAccounts.id, deliveries.channelAccountId)) + .where(eq(deliveries.id, input.deliveryId)).limit(1) + if (!row) throw errors.notFound('Delivery') + assertScope(data, { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episode.id }) + if (row.render.status !== 'APPROVED' || !row.render.storageKey) throw errors.conflict('Only an approved stored render can be published') + + await db.update(deliveries).set({ status: 'PUBLISHING', errorMessage: null, updatedAt: new Date() }) + .where(eq(deliveries.id, row.delivery.id)) + try { + if (row.delivery.platform === 'DOWNLOAD') { + await ensureNotCancelled(data) + const publishedUrl = `/api/v1/deliveries/${row.delivery.id}/download` + await db.transaction(async (tx) => { + await tx.update(deliveries).set({ + status: 'PUBLISHED', + publishedUrl, + publishedAt: new Date(), + updatedAt: new Date(), + }).where(eq(deliveries.id, row.delivery.id)) + await tx.update(episodes).set({ status: 'PUBLISHED', updatedAt: new Date() }).where(eq(episodes.id, row.episode.id)) + }) + await publishRealtimeEvent({ + workspaceId: row.project.workspaceId, + type: 'delivery.updated', + resourceType: 'delivery', + resourceId: row.delivery.id, + payload: { id: row.delivery.id, platform: 'DOWNLOAD', status: 'PUBLISHED', publishedUrl }, + }) + return { + provider: 'local', + providerModel: 'download-delivery-v1', + output: { deliveryId: row.delivery.id, status: 'PUBLISHED', publishedUrl, renderId: row.render.id }, + } + } + + if (!externalPublishingPlatforms.includes(row.delivery.platform as (typeof externalPublishingPlatforms)[number])) { + throw errors.badRequest(`Publishing platform ${row.delivery.platform} is unsupported`) + } + const platform = row.delivery.platform as (typeof externalPublishingPlatforms)[number] + const adapter = publishingAdapters.get(platform) + const readiness = adapter?.readiness() + if (!adapter || !readiness || readiness.status !== 'CONFIGURED') { + throw errors.serviceUnavailable(`${platform} publishing adapter is not configured`, { + platform, + missingRequirements: readiness?.missingRequirements ?? ['PUBLISHING_ADAPTER'], + }) + } + if (!row.channelAccount || row.channelAccount.workspaceId !== row.project.workspaceId || row.channelAccount.platform !== platform) { + throw errors.conflict('Delivery channel account is invalid') + } + if (row.channelAccount.status !== 'CONNECTED' || !row.channelAccount.accessTokenCiphertext) { + throw errors.conflict('Publishing channel account is not connected') + } + const channelAccount = row.channelAccount + + await ensureNotCancelled(data) + const result = await adapter.publish({ + deliveryId: row.delivery.id, + sourceUrl: await presignDownload(row.render.storageKey, 30 * 60), + callbackUrl: publishingConnectorCallbackUrl(), + account: { + externalAccountId: channelAccount.externalAccountId, + accessToken: decryptCredential(channelAccount.accessTokenCiphertext!), + ...(channelAccount.refreshTokenCiphertext ? { refreshToken: decryptCredential(channelAccount.refreshTokenCiphertext) } : {}), + }, + destination: row.delivery.destination, + }) + await ensureNotCancelled(data) + const completedAt = new Date() + await db.transaction(async (tx) => { + await tx.update(deliveries).set({ + status: result.status, + externalId: result.externalId, + publishedUrl: result.publishedUrl ?? null, + publishedAt: result.status === 'PUBLISHED' ? completedAt : null, + errorMessage: null, + updatedAt: completedAt, + }).where(and(eq(deliveries.id, row.delivery.id), eq(deliveries.status, 'PUBLISHING'))) + if (result.credentials) { + await tx.update(publishingChannelAccounts).set({ + accessTokenCiphertext: encryptCredential(result.credentials.accessToken), + ...(result.credentials.refreshToken ? { refreshTokenCiphertext: encryptCredential(result.credentials.refreshToken) } : {}), + ...(result.credentials.tokenExpiresAt ? { tokenExpiresAt: new Date(result.credentials.tokenExpiresAt) } : {}), + ...(result.credentials.scopes ? { scopes: result.credentials.scopes } : {}), + status: 'CONNECTED', + lastVerifiedAt: completedAt, + errorCode: null, + errorMessage: null, + updatedAt: completedAt, + }).where(eq(publishingChannelAccounts.id, channelAccount.id)) + } + if (result.status === 'PUBLISHED') { + await tx.update(episodes).set({ status: 'PUBLISHED', updatedAt: completedAt }).where(eq(episodes.id, row.episode.id)) + } + }) + await publishRealtimeEvent({ + workspaceId: row.project.workspaceId, + type: 'delivery.updated', + resourceType: 'delivery', + resourceId: row.delivery.id, + payload: { id: row.delivery.id, platform, status: result.status, externalId: result.externalId, publishedUrl: result.publishedUrl }, + }) + return { + provider: 'publishing-connector', + providerModel: `${platform.toLowerCase()}-delivery-v1`, + output: { deliveryId: row.delivery.id, status: result.status, externalId: result.externalId, publishedUrl: result.publishedUrl, renderId: row.render.id }, + } + } catch (error) { + const [failed] = await db.update(deliveries).set({ + status: 'FAILED', + errorMessage: error instanceof Error ? error.message : 'Delivery failed', + updatedAt: new Date(), + }).where(and(eq(deliveries.id, row.delivery.id), eq(deliveries.status, 'PUBLISHING'))).returning({ id: deliveries.id }) + if (failed) { + await publishRealtimeEvent({ + workspaceId: row.project.workspaceId, + type: 'delivery.updated', + resourceType: 'delivery', + resourceId: row.delivery.id, + payload: { id: row.delivery.id, platform: row.delivery.platform, status: 'FAILED', errorMessage: error instanceof Error ? error.message : 'Delivery failed' }, + }) + } + throw error + } +} + +export async function processGenerationJob(data: GenerationJobData, options: { publishingAdapters?: PublishingAdapterRegistry } = {}): Promise { + if (data.type === 'PIPELINE_HEALTHCHECK') { + return { + provider: 'local', + providerModel: 'healthcheck-v1', + output: { + ok: true, + checkedAt: new Date().toISOString(), + echo: data.input, + }, + } + } + + if (data.type === 'SCRIPT_GENERATE') return processScript(data) + if (data.type === 'STORYBOARD_GENERATE') return processStoryboard(data) + if (data.type === 'ASSET_GENERATE') return processAsset(data) + if (data.type === 'TTS_GENERATE') return processSpeech(data) + if (data.type === 'VIDEO_GENERATE') return processVideo(data) + if (data.type === 'LIPSYNC_GENERATE') return processLipsync(data) + if (data.type === 'EPISODE_RENDER') return processRender(data) + if (data.type === 'DELIVERY_PUBLISH') return processDelivery(data, options.publishingAdapters ?? createPublishingAdapterRegistry()) + + throw new Error(`Processor for ${data.type} is not configured yet`) +} diff --git a/server/src/queue/production-plan.test.ts b/server/src/queue/production-plan.test.ts new file mode 100644 index 0000000..44b50a1 --- /dev/null +++ b/server/src/queue/production-plan.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { buildShotProductionChain, flattenProductionPlan, type ProductionPlanSettings, type ProductionShotState } from './production-plan.js' + +const settings: ProductionPlanSettings = { + mode: 'MISSING', + imageProvider: 'openai', + videoProvider: 'replicate', + imageWidth: 1536, + imageHeight: 1024, + voice: 'alloy', + speechSpeed: 1, + enableLipsync: true, + videoParams: {}, + lipsyncParams: {}, +} + +function shot(overrides: Partial = {}): ProductionShotState { + return { + id: '10000000-0000-4000-8000-000000000001', + title: '雨夜天台', + description: '角色推门进入雨夜天台', + durationMs: 3200, + subtitleText: '你终于来了。', + imageAssetVersionId: null, + imageStorageKey: null, + videoStorageKey: null, + audioStorageKey: null, + generationParams: {}, + ...overrides, + } +} + +describe('whole-episode production planner', () => { + it('builds a deterministic per-shot dependency chain', () => { + const chain = buildShotProductionChain(shot(), settings) + expect(chain?.type).toBe('LIPSYNC_GENERATE') + expect(chain?.children?.[0]?.type).toBe('TTS_GENERATE') + expect(chain?.children?.[0]?.children?.[0]?.type).toBe('VIDEO_GENERATE') + expect(chain?.children?.[0]?.children?.[0]?.children?.[0]?.type).toBe('ASSET_GENERATE') + expect(flattenProductionPlan([chain!]).map((job) => job.type)).toEqual([ + 'LIPSYNC_GENERATE', + 'TTS_GENERATE', + 'VIDEO_GENERATE', + 'ASSET_GENERATE', + ]) + }) + + it('only fills missing media and skips a completed lipsynced shot', () => { + expect(buildShotProductionChain(shot({ + imageStorageKey: 'workspace/project/image.png', + videoStorageKey: 'workspace/project/shots/lipsync/video.mp4', + audioStorageKey: 'workspace/project/audio.mp3', + }), settings)).toBeNull() + + const videoOnly = buildShotProductionChain(shot({ + subtitleText: '', + imageStorageKey: 'workspace/project/image.png', + }), settings) + expect(videoOnly?.type).toBe('VIDEO_GENERATE') + expect(videoOnly?.children).toBeUndefined() + }) + + it('regenerates every enabled stage when requested', () => { + const chain = buildShotProductionChain(shot({ + imageStorageKey: 'workspace/project/image.png', + videoStorageKey: 'workspace/project/shots/lipsync/video.mp4', + audioStorageKey: 'workspace/project/audio.mp3', + generationParams: { operation: 'lipsync' }, + }), { ...settings, mode: 'REGENERATE' }) + expect(flattenProductionPlan([chain!]).map((job) => job.type)).toEqual([ + 'LIPSYNC_GENERATE', + 'TTS_GENERATE', + 'VIDEO_GENERATE', + 'ASSET_GENERATE', + ]) + }) + + it('omits lip-sync while still producing picture, video and speech', () => { + const chain = buildShotProductionChain(shot(), { ...settings, enableLipsync: false }) + expect(flattenProductionPlan([chain!]).map((job) => job.type)).toEqual([ + 'TTS_GENERATE', + 'VIDEO_GENERATE', + 'ASSET_GENERATE', + ]) + }) +}) diff --git a/server/src/queue/production-plan.ts b/server/src/queue/production-plan.ts new file mode 100644 index 0000000..26f0fff --- /dev/null +++ b/server/src/queue/production-plan.ts @@ -0,0 +1,109 @@ +import type { JobKind } from './types.js' + +export interface ProductionPlanNode { + type: JobKind + input: Record + children?: ProductionPlanNode[] +} + +export interface ProductionShotState { + id: string + title: string + description: string + durationMs: number + subtitleText: string + imageAssetVersionId: string | null + imageStorageKey: string | null + videoStorageKey: string | null + audioStorageKey: string | null + generationParams: Record +} + +export interface ProductionPlanSettings { + mode: 'MISSING' | 'REGENERATE' + imageProvider: 'openai' | 'replicate' | 'agnes' | 'generic' + videoProvider: 'replicate' | 'agnes' | 'generic' + imageWidth: number + imageHeight: number + voice: string + speechSpeed: number + enableLipsync: boolean + videoParams: Record + lipsyncParams: Record +} + +function append(parent: ProductionPlanNode | null, next: ProductionPlanNode) { + if (parent) next.children = [parent] + return next +} + +function isLipsynced(shot: ProductionShotState) { + return shot.generationParams.operation === 'lipsync' + || Boolean(shot.videoStorageKey?.includes('/shots/lipsync/')) +} + +export function buildShotProductionChain(shot: ProductionShotState, settings: ProductionPlanSettings) { + const regenerate = settings.mode === 'REGENERATE' + const dialogue = shot.subtitleText.trim() + const needsImage = regenerate || (!shot.imageStorageKey && !shot.videoStorageKey) + const needsVideo = regenerate || !shot.videoStorageKey + const needsSpeech = Boolean(dialogue) && (regenerate || !shot.audioStorageKey) + const needsLipsync = settings.enableLipsync + && Boolean(dialogue) + && (regenerate || needsVideo || needsSpeech || !isLipsynced(shot)) + + let chain: ProductionPlanNode | null = null + if (needsImage) { + chain = append(chain, { + type: 'ASSET_GENERATE', + input: { + shotId: shot.id, + prompt: shot.description.trim() || shot.title, + width: settings.imageWidth, + height: settings.imageHeight, + provider: settings.imageProvider, + }, + }) + } + if (needsVideo) { + chain = append(chain, { + type: 'VIDEO_GENERATE', + input: { + shotId: shot.id, + prompt: shot.description.trim() || shot.title, + durationSeconds: shot.durationMs / 1000, + provider: settings.videoProvider, + params: settings.videoParams, + }, + }) + } + if (needsSpeech) { + chain = append(chain, { + type: 'TTS_GENERATE', + input: { + shotId: shot.id, + text: dialogue, + voice: settings.voice, + speed: settings.speechSpeed, + provider: 'openai', + }, + }) + } + if (needsLipsync) { + chain = append(chain, { + type: 'LIPSYNC_GENERATE', + input: { shotId: shot.id, provider: 'replicate', params: settings.lipsyncParams }, + }) + } + return chain +} + +export function flattenProductionPlan(nodes: ProductionPlanNode[]) { + const flattened: ProductionPlanNode[] = [] + const visit = (node: ProductionPlanNode) => { + flattened.push(node) + node.children?.forEach(visit) + } + nodes.forEach(visit) + return flattened +} diff --git a/server/src/queue/render-processor.integration.test.ts b/server/src/queue/render-processor.integration.test.ts new file mode 100644 index 0000000..f737f57 --- /dev/null +++ b/server/src/queue/render-processor.integration.test.ts @@ -0,0 +1,202 @@ +import { readFile } from 'node:fs/promises' +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { + assets, + assetVersions, + generationJobs, + renders, + shots, + shotVersions, + users, + workspaces, +} from '../db/schema.js' +import { deleteObject, getObjectBuffer, putObject } from '../storage/client.js' +import { processGenerationJob } from './processors.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('render processor artifacts', () => { + let app: FastifyInstance + let session: Session + let workspaceId = '' + let projectId = '' + let episodeId = '' + const objectKeys: string[] = [] + + async function request(method: string, url: string, payload?: unknown) { + const response = await app.inject({ + method, + url, + headers: { authorization: `Bearer ${session.accessToken}` }, + payload, + }) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + beforeAll(async () => { + app = await buildApp() + const nonce = crypto.randomUUID().slice(0, 8) + const registration = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `render-processor-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: 'Render processor account', + workspaceName: `Render processor workspace ${nonce}`, + }, + }) + expect(registration.statusCode, registration.body).toBe(201) + session = registration.json() + workspaceId = session.workspaces[0].id + + const project = await request('POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '字幕产物测试项目', + logline: '验证渲染版本清单与字幕侧车文件。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + const episode = await request('POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '字幕产物测试集', + synopsis: '', + targetDurationSeconds: 30, + }) + episodeId = episode.id + }) + + afterAll(async () => { + await Promise.allSettled(objectKeys.map((key) => deleteObject(key))) + if (workspaceId) await db.delete(workspaces).where(eq(workspaces.id, workspaceId)) + if (session?.user.id) await db.delete(users).where(eq(users.id, session.user.id)) + await app.close() + }) + + it('stores immutable source versions plus matching SRT and VTT objects', async () => { + const sourceImageKey = `${workspaceId}/${projectId}/generated/test/render-source.jpg` + objectKeys.push(sourceImageKey) + await putObject({ + key: sourceImageKey, + body: await readFile(new URL('../../../public/assets/scene-rooftop.jpg', import.meta.url)), + contentType: 'image/jpeg', + }) + + const [asset] = await db.insert(assets).values({ + projectId, + type: 'SHOT', + name: '渲染测试画面', + status: 'APPROVED', + createdById: session.user.id, + }).returning() + if (!asset) throw new Error('Failed to create render source asset') + const [assetVersion] = await db.insert(assetVersions).values({ + assetId: asset.id, + version: 1, + storageKey: sourceImageKey, + mimeType: 'image/jpeg', + width: 1280, + height: 720, + isCurrent: true, + createdById: session.user.id, + }).returning() + if (!assetVersion) throw new Error('Failed to create render source version') + + const [shot] = await db.insert(shots).values({ + episodeId, + shotNumber: 1, + sceneNumber: 1, + title: '字幕镜头', + description: '用于验证字幕侧车文件。', + shotType: '全景', + cameraMotion: '静止', + durationMs: 1_200, + sortOrder: 100, + status: 'APPROVED', + createdById: session.user.id, + }).returning() + if (!shot) throw new Error('Failed to create render source shot') + const [shotVersion] = await db.insert(shotVersions).values({ + shotId: shot.id, + version: 1, + imageAssetVersionId: assetVersion.id, + subtitleText: '不可变字幕版本', + isCurrent: true, + createdById: session.user.id, + }).returning() + if (!shotVersion) throw new Error('Failed to create render source shot version') + + const [render] = await db.insert(renders).values({ + episodeId, + version: 1, + width: 640, + height: 360, + fps: 24, + settings: { + width: 640, + height: 360, + fps: 24, + quality: 'draft', + burnSubtitles: true, + normalizeAudio: false, + }, + createdById: session.user.id, + }).returning() + if (!render) throw new Error('Failed to create test render') + const [job] = await db.insert(generationJobs).values({ + workspaceId, + projectId, + episodeId, + createdById: session.user.id, + type: 'EPISODE_RENDER', + status: 'RUNNING', + progress: 10, + idempotencyKey: `render-processor-${render.id}`, + input: { renderId: render.id }, + estimatedCredits: '1', + }).returning() + if (!job) throw new Error('Failed to create test render job') + + const result = await processGenerationJob({ + databaseId: job.id, + workspaceId, + projectId, + episodeId, + userId: session.user.id, + type: 'EPISODE_RENDER', + input: { renderId: render.id }, + }) + expect(result.output).toMatchObject({ + renderId: render.id, + subtitleFormats: ['srt', 'vtt'], + sourceShotCount: 1, + }) + + const [stored] = await db.select().from(renders).where(eq(renders.id, render.id)).limit(1) + expect(stored?.status).toBe('REVIEW') + expect(stored?.sourceManifest).toMatchObject({ + schemaVersion: 1, + shots: [{ shotId: shot.id, shotVersionId: shotVersion.id, subtitleText: '不可变字幕版本' }], + }) + expect(stored?.subtitleSrtKey).toBeTruthy() + expect(stored?.subtitleVttKey).toBeTruthy() + expect(stored?.storageKey).toBeTruthy() + objectKeys.push(...[stored?.subtitleSrtKey, stored?.subtitleVttKey, stored?.storageKey].filter((key): key is string => Boolean(key))) + + expect((await getObjectBuffer(stored!.subtitleSrtKey!)).toString('utf8')).toContain('00:00:00,000 --> 00:00:01,200\n不可变字幕版本') + expect((await getObjectBuffer(stored!.subtitleVttKey!)).toString('utf8')).toContain('WEBVTT\n\n00:00:00.000 --> 00:00:01.200\n不可变字幕版本') + + const persistedVersions = await db.select({ id: shotVersions.id }).from(shotVersions) + .where(inArray(shotVersions.id, stored!.sourceManifest!.shots.map((item) => item.shotVersionId))) + expect(persistedVersions).toEqual([{ id: shotVersion.id }]) + }, 30_000) +}) diff --git a/server/src/queue/types.ts b/server/src/queue/types.ts new file mode 100644 index 0000000..e480a62 --- /dev/null +++ b/server/src/queue/types.ts @@ -0,0 +1,34 @@ +export const GENERATION_QUEUE = 'frameflow-generation' + +export type JobKind = + | 'PIPELINE_HEALTHCHECK' + | 'SCRIPT_GENERATE' + | 'STORYBOARD_GENERATE' + | 'ASSET_GENERATE' + | 'TTS_GENERATE' + | 'VIDEO_GENERATE' + | 'LIPSYNC_GENERATE' + | 'EPISODE_RENDER' + | 'DELIVERY_PUBLISH' + +export interface GenerationJobData { + databaseId: string + workspaceId: string + projectId?: string + episodeId?: string + userId: string + type: JobKind + input: Record +} + +export interface GenerationJobResult { + provider?: string + providerModel?: string + output: Record + usage?: Array<{ + kind: 'TEXT_INPUT_TOKEN' | 'TEXT_OUTPUT_TOKEN' | 'IMAGE' | 'AUDIO_SECOND' | 'VIDEO_SECOND' | 'RENDER_SECOND' + quantity: number + credits: number + providerCostUsd?: number + }> +} diff --git a/server/src/routes/account-recovery.integration.test.ts b/server/src/routes/account-recovery.integration.test.ts new file mode 100644 index 0000000..bdd6611 --- /dev/null +++ b/server/src/routes/account-recovery.integration.test.ts @@ -0,0 +1,203 @@ +import type { FastifyInstance } from 'fastify' +import { and, eq, isNull } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { accountActionTokens, auditLogs, refreshTokens, users, workspaces } from '../db/schema.js' +import { hashToken } from '../lib/auth.js' + +interface Session { + accessToken: string + user: { id: string; email: string; emailVerified: boolean } + workspaces: Array<{ id: string }> +} + +function responseCookie(response: { headers: Record }) { + const header = response.headers['set-cookie'] + const value = Array.isArray(header) ? header[0] : String(header ?? '') + return value.split(';')[0] +} + +function actionToken(url: string, parameter: string) { + const token = new URL(url).searchParams.get(parameter) + if (!token) throw new Error(`Missing ${parameter} token`) + return token +} + +describe.sequential('email verification and password recovery', () => { + let app: FastifyInstance + let session: Session + let registrationCookie = '' + let secondaryCookie = '' + const initialPassword = 'RecoveryInitial2026!' + const nextPassword = 'RecoveryChanged2026!' + + async function inject(input: { method: string; url: string; payload?: unknown; cookie?: string }) { + return app.inject({ + method: input.method, + url: input.url, + headers: input.cookie ? { cookie: input.cookie } : {}, + payload: input.payload, + }) + } + + beforeAll(async () => { + app = await buildApp() + const nonce = crypto.randomUUID().slice(0, 8) + const response = await inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `recovery-${nonce}@frameflow.test`, + password: initialPassword, + displayName: 'Recovery Test', + workspaceName: `Recovery Workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + session = response.json() + registrationCookie = responseCookie(response) + }) + + afterAll(async () => { + if (session?.user.id) await db.delete(auditLogs).where(eq(auditLogs.resourceId, session.user.id)) + if (session?.workspaces[0]?.id) await db.delete(workspaces).where(eq(workspaces.id, session.workspaces[0].id)) + if (session?.user.id) await db.delete(users).where(eq(users.id, session.user.id)) + await app.close() + }) + + it('issues hashed single-use verification links without exposing unknown accounts', async () => { + expect(session.user.emailVerified).toBe(true) + await db.update(users).set({ emailVerifiedAt: null }).where(eq(users.id, session.user.id)) + + const unknown = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/request', + payload: { email: `unknown-${crypto.randomUUID()}@frameflow.test` }, + }) + expect(unknown.statusCode, unknown.body).toBe(202) + expect(unknown.json()).toEqual(expect.objectContaining({ accepted: true })) + expect(unknown.json().debugVerificationUrl).toBeUndefined() + + const first = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/request', + payload: { email: session.user.email.toUpperCase() }, + }) + const firstToken = actionToken(first.json().debugVerificationUrl, 'verify_email') + const [stored] = await db.select().from(accountActionTokens).where(eq(accountActionTokens.tokenHash, hashToken(firstToken))).limit(1) + expect(stored?.tokenHash).toBe(hashToken(firstToken)) + expect(stored?.tokenHash).not.toBe(firstToken) + + const second = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/request', + payload: { email: session.user.email }, + }) + const secondToken = actionToken(second.json().debugVerificationUrl, 'verify_email') + const stale = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/confirm', + payload: { token: firstToken }, + }) + expect(stale.statusCode, stale.body).toBe(400) + + const confirmed = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/confirm', + payload: { token: secondToken }, + }) + expect(confirmed.statusCode, confirmed.body).toBe(200) + expect(confirmed.json().user.emailVerified).toBe(true) + const [verified] = await db.select({ emailVerifiedAt: users.emailVerifiedAt }).from(users).where(eq(users.id, session.user.id)).limit(1) + expect(verified?.emailVerifiedAt).toBeInstanceOf(Date) + + const replayed = await inject({ + method: 'POST', + url: '/api/v1/auth/verify-email/confirm', + payload: { token: secondToken }, + }) + expect(replayed.statusCode, replayed.body).toBe(400) + }) + + it('resets the password once and revokes every existing refresh session', async () => { + const secondaryLogin = await inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: session.user.email, password: initialPassword }, + }) + expect(secondaryLogin.statusCode, secondaryLogin.body).toBe(200) + secondaryCookie = responseCookie(secondaryLogin) + + const unknown = await inject({ + method: 'POST', + url: '/api/v1/auth/password/forgot', + payload: { email: `unknown-${crypto.randomUUID()}@frameflow.test` }, + }) + expect(unknown.statusCode, unknown.body).toBe(202) + expect(unknown.json().debugResetUrl).toBeUndefined() + + const first = await inject({ + method: 'POST', + url: '/api/v1/auth/password/forgot', + payload: { email: session.user.email }, + }) + const firstToken = actionToken(first.json().debugResetUrl, 'reset_password') + const second = await inject({ + method: 'POST', + url: '/api/v1/auth/password/forgot', + payload: { email: session.user.email }, + }) + const secondToken = actionToken(second.json().debugResetUrl, 'reset_password') + + const stale = await inject({ + method: 'POST', + url: '/api/v1/auth/password/reset', + payload: { token: firstToken, newPassword: nextPassword }, + }) + expect(stale.statusCode, stale.body).toBe(400) + + const reusedPassword = await inject({ + method: 'POST', + url: '/api/v1/auth/password/reset', + payload: { token: secondToken, newPassword: initialPassword }, + }) + expect(reusedPassword.statusCode, reusedPassword.body).toBe(409) + + const reset = await inject({ + method: 'POST', + url: '/api/v1/auth/password/reset', + payload: { token: secondToken, newPassword: nextPassword }, + }) + expect(reset.statusCode, reset.body).toBe(204) + const activeSessions = await db.select({ id: refreshTokens.id }).from(refreshTokens).where(and( + eq(refreshTokens.userId, session.user.id), + isNull(refreshTokens.revokedAt), + )) + expect(activeSessions).toHaveLength(0) + + for (const cookie of [registrationCookie, secondaryCookie]) { + const refresh = await inject({ method: 'POST', url: '/api/v1/auth/refresh', cookie }) + expect(refresh.statusCode, refresh.body).toBe(401) + } + const oldLogin = await inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: session.user.email, password: initialPassword }, + }) + expect(oldLogin.statusCode, oldLogin.body).toBe(401) + const newLogin = await inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: session.user.email, password: nextPassword }, + }) + expect(newLogin.statusCode, newLogin.body).toBe(200) + + const replayed = await inject({ + method: 'POST', + url: '/api/v1/auth/password/reset', + payload: { token: secondToken, newPassword: 'AnotherPassword2026!' }, + }) + expect(replayed.statusCode, replayed.body).toBe(400) + }) +}) diff --git a/server/src/routes/assets.integration.test.ts b/server/src/routes/assets.integration.test.ts new file mode 100644 index 0000000..a5fe7c3 --- /dev/null +++ b/server/src/routes/assets.integration.test.ts @@ -0,0 +1,219 @@ +import type { FastifyInstance } from 'fastify' +import { inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('asset management and media isolation', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + let assetId = '' + let assetVersionId = '' + let musicAssetId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: { authorization: `Bearer ${token}` }, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.statusCode === 204 ? null : response.json() + } + + async function register(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp() + owner = await register('asset-owner') + viewer = await register('asset-viewer') + workspaceId = owner.workspaces[0].id + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '资产治理测试项目', + logline: '验证资产筛选、生命周期和媒体隔离。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + }) + + afterAll(async () => { + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('edits, filters, archives, and restores project assets', async () => { + const character = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/assets`, { + type: 'CHARACTER', + name: '林遥测试角色', + description: '初始描述', + tags: ['人物'], + metadata: { importSource: 'character-bible' }, + }) + assetId = character.id + const music = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/assets`, { + type: 'MUSIC', + name: '雨夜配乐', + description: '氛围音乐', + tags: ['音乐'], + metadata: {}, + }) + musicAssetId = music.id + + const updated = await request(owner.accessToken, 'PATCH', `/api/v1/assets/${assetId}`, { + name: '林遥角色定稿', + description: '短发调查记者,深色风衣。', + tags: ['人物', '主角'], + status: 'APPROVED', + }) + expect(updated.name).toBe('林遥角色定稿') + expect(updated.tags).toEqual(['人物', '主角']) + + const approvedCharacters = await request(owner.accessToken, 'GET', `/api/v1/projects/${projectId}/assets?type=CHARACTER&status=APPROVED`) + expect(approvedCharacters).toHaveLength(1) + expect(approvedCharacters[0].asset.id).toBe(assetId) + + await request(owner.accessToken, 'PATCH', `/api/v1/assets/${assetId}`, { status: 'ARCHIVED' }) + const archived = await request(owner.accessToken, 'GET', `/api/v1/projects/${projectId}/assets?status=ARCHIVED`) + expect(archived.map((row: { asset: { id: string } }) => row.asset.id)).toContain(assetId) + + const restored = await request(owner.accessToken, 'PATCH', `/api/v1/assets/${assetId}`, { status: 'DRAFT' }) + expect(restored.status).toBe('DRAFT') + }) + + it('rejects asset media keys outside the owning project', async () => { + const foreignKey = `${viewer.workspaces[0].id}/foreign-project/uploads/foreign.png` + const rejected = await inject(owner.accessToken, 'POST', `/api/v1/assets/${assetId}/versions`, { + storageKey: foreignKey, + mimeType: 'image/png', + generationParams: { source: 'test' }, + }) + expect(rejected.statusCode, rejected.body).toBe(403) + expect(rejected.json().error.code).toBe('FORBIDDEN') + + const accepted = await request(owner.accessToken, 'POST', `/api/v1/assets/${assetId}/versions`, { + storageKey: `${workspaceId}/${projectId}/uploads/owned.png`, + thumbnailKey: `${workspaceId}/${projectId}/uploads/owned-thumb.png`, + mimeType: 'image/png', + generationParams: { source: 'test' }, + }) + expect(accepted.version).toBe(1) + expect(accepted.isCurrent).toBe(true) + assetVersionId = accepted.id + }) + + it('persists and validates a locked character consistency profile', async () => { + const otherCharacter = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/assets`, { + type: 'CHARACTER', + name: '其他测试角色', + description: '', + tags: [], + metadata: {}, + }) + const otherVersion = await request(owner.accessToken, 'POST', `/api/v1/assets/${otherCharacter.id}/versions`, { + storageKey: `${workspaceId}/${projectId}/uploads/other-character.png`, + mimeType: 'image/png', + generationParams: { source: 'test' }, + }) + + const wrongAsset = await inject(owner.accessToken, 'PUT', `/api/v1/assets/${assetId}/consistency-profile`, { + referenceAssetVersionIds: [otherVersion.id], + identityLocked: true, + }) + expect(wrongAsset.statusCode, wrongAsset.body).toBe(400) + + const missingReference = await inject(owner.accessToken, 'PUT', `/api/v1/assets/${assetId}/consistency-profile`, { + referenceAssetVersionIds: [], + identityLocked: true, + }) + expect(missingReference.statusCode, missingReference.body).toBe(400) + + const wrongType = await inject(owner.accessToken, 'PUT', `/api/v1/assets/${musicAssetId}/consistency-profile`, { + referenceAssetVersionIds: [], + identityLocked: false, + }) + expect(wrongType.statusCode, wrongType.body).toBe(400) + + const saved = await request(owner.accessToken, 'PUT', `/api/v1/assets/${assetId}/consistency-profile`, { + referenceAssetVersionIds: [assetVersionId], + identityPrompt: '短发调查记者,面部轮廓固定', + costumePrompt: '深色风衣与银色胸针', + stylePrompt: '写实冷色漫剧', + negativePrompt: '脸型漂移,服装变色', + consistencyStrength: 91, + identityLocked: true, + }) + expect(saved.consistencyProfile).toMatchObject({ + referenceAssetVersionIds: [assetVersionId], + consistencyStrength: 91, + identityLocked: true, + schemaVersion: 1, + }) + expect(saved.asset.metadata.importSource).toBe('character-bible') + + const detail = await request(owner.accessToken, 'GET', `/api/v1/assets/${assetId}`) + expect(detail.metadata.consistencyProfile.identityPrompt).toContain('调查记者') + expect(detail.metadata.importSource).toBe('character-bible') + + const bypass = await inject(owner.accessToken, 'PATCH', `/api/v1/assets/${assetId}`, { + metadata: { consistencyProfile: { identityLocked: false } }, + }) + expect(bypass.statusCode, bypass.body).toBe(400) + }) + + it('keeps asset management read-only for viewers', async () => { + const assets = await request(viewer.accessToken, 'GET', `/api/v1/projects/${projectId}/assets`) + expect(assets.length).toBeGreaterThanOrEqual(2) + + const forbidden = await inject(viewer.accessToken, 'PATCH', `/api/v1/assets/${assetId}`, { + name: '访客不应改名', + }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + expect(forbidden.json().error.code).toBe('FORBIDDEN') + + const profileForbidden = await inject(viewer.accessToken, 'PUT', `/api/v1/assets/${assetId}/consistency-profile`, { + referenceAssetVersionIds: [assetVersionId], + identityLocked: true, + }) + expect(profileForbidden.statusCode, profileForbidden.body).toBe(403) + }) +}) diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts new file mode 100644 index 0000000..a5ef66b --- /dev/null +++ b/server/src/routes/assets.ts @@ -0,0 +1,213 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { assets, assetVersions, projects } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { + characterConsistencyProfileInputSchema, + createCharacterConsistencyProfile, +} from '../lib/character-consistency.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission } from '../lib/permissions.js' + +const projectParams = z.object({ projectId: z.uuid() }) +const assetParams = z.object({ assetId: z.uuid() }) +const assetVersionParams = z.object({ assetId: z.uuid(), versionId: z.uuid() }) +const listQuery = z.object({ + type: z.enum(['CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC', 'SHOT']).optional(), + status: z.enum(['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED']).optional(), +}) +const createAssetSchema = z.object({ + type: z.enum(['CHARACTER', 'SCENE', 'PROP', 'VOICE', 'MUSIC', 'SHOT']), + name: z.string().trim().min(1).max(180), + description: z.string().max(5000).default(''), + tags: z.array(z.string().trim().min(1).max(60)).max(30).default([]), + metadata: z.record(z.string(), z.unknown()).default({}), +}) +const updateAssetSchema = createAssetSchema.omit({ type: true }).partial().extend({ + status: z.enum(['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'ARCHIVED']).optional(), +}) +const assetVersionSchema = z.object({ + storageKey: z.string().max(1000).optional(), + thumbnailKey: z.string().max(1000).optional(), + mimeType: z.string().max(120).optional(), + byteSize: z.number().int().positive().optional(), + width: z.number().int().positive().optional(), + height: z.number().int().positive().optional(), + durationMs: z.number().int().positive().optional(), + prompt: z.string().max(20000).default(''), + negativePrompt: z.string().max(10000).default(''), + provider: z.string().max(80).optional(), + providerModel: z.string().max(160).optional(), + seed: z.string().max(80).optional(), + generationParams: z.record(z.string(), z.unknown()).default({}), +}) + +async function assetProject(assetId: string) { + const [row] = await db.select({ asset: assets, project: projects }).from(assets).innerJoin(projects, eq(projects.id, assets.projectId)).where(eq(assets.id, assetId)).limit(1) + if (!row) throw errors.notFound('Asset') + return row +} + +export async function assetRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/projects/:projectId/assets', async (request) => { + const { projectId } = parseWith(projectParams, request.params) + const query = parseWith(listQuery, request.query) + await requireProjectPermission(request, projectId, 'project:read') + const filters = [eq(assets.projectId, projectId)] + if (query.type) filters.push(eq(assets.type, query.type)) + if (query.status) filters.push(eq(assets.status, query.status)) + return db.select({ asset: assets, currentVersion: assetVersions }).from(assets).leftJoin(assetVersions, and( + eq(assetVersions.assetId, assets.id), + eq(assetVersions.isCurrent, true), + )).where(and(...filters)).orderBy(desc(assets.updatedAt)) + }) + + app.post('/projects/:projectId/assets', async (request, reply) => { + const { projectId } = parseWith(projectParams, request.params) + const input = parseWith(createAssetSchema, request.body) + const { project } = await requireProjectPermission(request, projectId, 'content:write') + if (Object.prototype.hasOwnProperty.call(input.metadata, 'consistencyProfile')) { + throw errors.badRequest('Use the consistency profile endpoint to manage character identity') + } + const [asset] = await db.insert(assets).values({ + projectId, + createdById: request.user.sub, + ...input, + }).returning() + if (!asset) throw new Error('Failed to create asset') + await audit({ request, workspaceId: project.workspaceId, action: 'asset.create', resourceType: 'asset', resourceId: asset.id, after: asset }) + return reply.code(201).send(asset) + }) + + app.get('/assets/:assetId', async (request) => { + const { assetId } = parseWith(assetParams, request.params) + const row = await assetProject(assetId) + await requireProjectPermission(request, row.project.id, 'project:read') + const versions = await db.select().from(assetVersions).where(eq(assetVersions.assetId, assetId)).orderBy(desc(assetVersions.version)) + return { ...row.asset, versions } + }) + + app.patch('/assets/:assetId', async (request) => { + const { assetId } = parseWith(assetParams, request.params) + const input = parseWith(updateAssetSchema, request.body) + const row = await assetProject(assetId) + await requireProjectPermission(request, row.project.id, 'content:write') + if (input.metadata && Object.prototype.hasOwnProperty.call(input.metadata, 'consistencyProfile')) { + throw errors.badRequest('Use the consistency profile endpoint to manage character identity') + } + const changes = { + ...input, + ...(input.metadata ? { metadata: { ...(row.asset.metadata ?? {}), ...input.metadata } } : {}), + updatedAt: new Date(), + } + const [updated] = await db.update(assets).set(changes).where(eq(assets.id, assetId)).returning() + if (!updated) throw errors.notFound('Asset') + await audit({ request, workspaceId: row.project.workspaceId, action: 'asset.update', resourceType: 'asset', resourceId: assetId, before: row.asset, after: updated }) + return updated + }) + + app.put('/assets/:assetId/consistency-profile', async (request) => { + const { assetId } = parseWith(assetParams, request.params) + const input = parseWith(characterConsistencyProfileInputSchema, request.body) + const row = await assetProject(assetId) + await requireProjectPermission(request, row.project.id, 'content:write') + if (row.asset.type !== 'CHARACTER') throw errors.badRequest('Consistency profiles are only available for character assets') + + const references = input.referenceAssetVersionIds.length > 0 + ? await db.select({ + id: assetVersions.id, + storageKey: assetVersions.storageKey, + mimeType: assetVersions.mimeType, + }).from(assetVersions).where(and( + eq(assetVersions.assetId, assetId), + inArray(assetVersions.id, input.referenceAssetVersionIds), + )) + : [] + if (references.length !== input.referenceAssetVersionIds.length) { + throw errors.badRequest('Every identity reference must belong to this character asset') + } + if (references.some((reference) => !reference.storageKey || !reference.mimeType?.startsWith('image/'))) { + throw errors.badRequest('Every identity reference must be a stored image version') + } + + const profile = createCharacterConsistencyProfile(input, request.user.sub) + const profilePatch = JSON.stringify({ consistencyProfile: profile }) + const [updated] = await db.update(assets).set({ + metadata: sql`${assets.metadata} || ${profilePatch}::jsonb`, + updatedAt: new Date(), + }).where(eq(assets.id, assetId)).returning() + if (!updated) throw errors.notFound('Asset') + await audit({ + request, + workspaceId: row.project.workspaceId, + action: 'asset.consistency_profile.update', + resourceType: 'asset', + resourceId: assetId, + before: { consistencyProfile: row.asset.metadata?.consistencyProfile ?? null }, + after: { consistencyProfile: profile }, + }) + return { asset: updated, consistencyProfile: profile } + }) + + app.post('/assets/:assetId/versions', async (request, reply) => { + const { assetId } = parseWith(assetParams, request.params) + const input = parseWith(assetVersionSchema, request.body) + const row = await assetProject(assetId) + await requireProjectPermission(request, row.project.id, 'content:write') + const projectPrefix = `${row.project.workspaceId}/${row.project.id}/` + for (const key of [input.storageKey, input.thumbnailKey]) { + if (key && !key.startsWith(projectPrefix)) throw errors.forbidden('Asset media must belong to this project') + } + + const created = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`asset:${assetId}`}))`) + const [latest] = await tx.select({ version: assetVersions.version }).from(assetVersions).where(eq(assetVersions.assetId, assetId)).orderBy(desc(assetVersions.version)).limit(1) + await tx.update(assetVersions).set({ isCurrent: false }).where(eq(assetVersions.assetId, assetId)) + const [version] = await tx.insert(assetVersions).values({ + assetId, + version: (latest?.version ?? 0) + 1, + ...input, + isCurrent: true, + createdById: request.user.sub, + }).returning() + return version + }) + if (!created) throw new Error('Failed to create asset version') + await audit({ request, workspaceId: row.project.workspaceId, action: 'asset.version.create', resourceType: 'asset_version', resourceId: created.id, after: { assetId, version: created.version } }) + return reply.code(201).send(created) + }) + + app.post('/assets/:assetId/versions/:versionId/activate', async (request) => { + const { assetId, versionId } = parseWith(assetVersionParams, request.params) + const row = await assetProject(assetId) + await requireProjectPermission(request, row.project.id, 'content:write') + + const result = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`asset:${assetId}`}))`) + const [version] = await tx.select().from(assetVersions).where(and( + eq(assetVersions.id, versionId), + eq(assetVersions.assetId, assetId), + )).limit(1) + if (!version) throw errors.notFound('Asset version') + if (version.isCurrent) return { version, changed: false } + await tx.update(assetVersions).set({ isCurrent: false }).where(eq(assetVersions.assetId, assetId)) + const [activated] = await tx.update(assetVersions).set({ isCurrent: true }).where(and( + eq(assetVersions.id, versionId), + eq(assetVersions.assetId, assetId), + )).returning() + if (!activated) throw errors.notFound('Asset version') + return { version: activated, changed: true } + }) + + if (result.changed) { + await audit({ request, workspaceId: row.project.workspaceId, action: 'asset.version.activate', resourceType: 'asset_version', resourceId: versionId, after: { assetId, version: result.version.version } }) + } + return result.version + }) +} diff --git a/server/src/routes/audit.integration.test.ts b/server/src/routes/audit.integration.test.ts new file mode 100644 index 0000000..4f16d41 --- /dev/null +++ b/server/src/routes/audit.integration.test.ts @@ -0,0 +1,129 @@ +import type { FastifyInstance } from 'fastify' +import { inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('workspace audit log access', () => { + let app: FastifyInstance + let owner: Session + let member: Session + let workspaceId = '' + let otherWorkspaceId = '' + let projectId = '' + const userIds: string[] = [] + const workspaceIds: string[] = [] + + async function inject(token: string | null, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: token ? { authorization: `Bearer ${token}` } : undefined, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + async function register(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + workspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp() + owner = await register('audit-owner') + member = await register('audit-member') + workspaceId = owner.workspaces[0].id + otherWorkspaceId = member.workspaces[0].id + + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: member.user.email, + role: 'VIEWER', + }) + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '审计日志测试项目', + logline: '审计查询测试', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + await request(owner.accessToken, 'PATCH', `/api/v1/projects/${projectId}`, { + status: 'ACTIVE', + logline: '更新后的审计查询测试', + }) + await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '审计测试分集', + synopsis: '', + targetDurationSeconds: 60, + }) + }) + + afterAll(async () => { + if (workspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, workspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('restricts audit history to workspace administrators', async () => { + const unauthenticated = await inject(null, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs`) + expect(unauthenticated.statusCode).toBe(401) + + const forbidden = await inject(member.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs`) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + await request(owner.accessToken, 'PATCH', `/api/v1/workspaces/${workspaceId}/members/${member.user.id}`, { role: 'ADMIN' }) + const adminPage = await request(member.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs?limit=10`) + expect(adminPage.items.length).toBeGreaterThan(0) + }) + + it('returns actor context and applies category and actor filters', async () => { + const projectPage = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs?category=project&actorId=${owner.user.id}`) + expect(projectPage.items.map((item: { action: string }) => item.action)).toEqual(expect.arrayContaining(['project.create', 'project.update'])) + expect(projectPage.items.every((item: { action: string; actor: { id: string } | null }) => item.action.startsWith('project.') && item.actor?.id === owner.user.id)).toBe(true) + + const updated = projectPage.items.find((item: { action: string }) => item.action === 'project.update') + expect(updated.before.logline).toBe('审计查询测试') + expect(updated.after.logline).toBe('更新后的审计查询测试') + }) + + it('paginates without duplicates and rejects cursors from another workspace', async () => { + const first = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs?limit=2`) + expect(first.items).toHaveLength(2) + expect(first.nextCursor).toBeTruthy() + + const second = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs?limit=2&cursor=${first.nextCursor}`) + expect(second.items.length).toBeGreaterThan(0) + expect(second.items.some((item: { id: string }) => first.items.some((firstItem: { id: string }) => firstItem.id === item.id))).toBe(false) + + const otherPage = await request(member.accessToken, 'GET', `/api/v1/workspaces/${otherWorkspaceId}/audit-logs?limit=1`) + const invalidCursor = await inject(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/audit-logs?cursor=${otherPage.items[0].id}`) + expect(invalidCursor.statusCode, invalidCursor.body).toBe(400) + }) +}) diff --git a/server/src/routes/audit.ts b/server/src/routes/audit.ts new file mode 100644 index 0000000..b335ab7 --- /dev/null +++ b/server/src/routes/audit.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq, like, lt, or } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { auditLogs, users } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireWorkspacePermission } from '../lib/permissions.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const auditQuery = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), + cursor: z.uuid().optional(), + action: z.string().trim().min(1).max(120).optional(), + category: z.enum(['auth', 'member', 'project', 'episode', 'script', 'asset', 'shot', 'shots', 'job', 'production_run', 'review', 'render', 'delivery', 'billing', 'upload', 'service']).optional(), + resourceType: z.string().trim().min(1).max(80).optional(), + actorId: z.uuid().optional(), +}) + +export async function auditRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/audit-logs', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const query = parseWith(auditQuery, request.query) + await requireWorkspacePermission(request, workspaceId, 'audit:read') + + const filters = [eq(auditLogs.workspaceId, workspaceId)] + if (query.action) filters.push(eq(auditLogs.action, query.action)) + if (query.category) filters.push(like(auditLogs.action, `${query.category}.%`)) + if (query.resourceType) filters.push(eq(auditLogs.resourceType, query.resourceType)) + if (query.actorId) filters.push(eq(auditLogs.actorId, query.actorId)) + + if (query.cursor) { + const [cursor] = await db.select({ id: auditLogs.id, createdAt: auditLogs.createdAt }).from(auditLogs).where(and( + eq(auditLogs.id, query.cursor), + eq(auditLogs.workspaceId, workspaceId), + )).limit(1) + if (!cursor) throw errors.badRequest('Audit cursor is invalid for this workspace') + const cursorFilter = or( + lt(auditLogs.createdAt, cursor.createdAt), + and(eq(auditLogs.createdAt, cursor.createdAt), lt(auditLogs.id, cursor.id)), + ) + if (cursorFilter) filters.push(cursorFilter) + } + + const rows = await db.select({ + log: auditLogs, + actor: { + id: users.id, + email: users.email, + displayName: users.displayName, + }, + }).from(auditLogs) + .leftJoin(users, eq(users.id, auditLogs.actorId)) + .where(and(...filters)) + .orderBy(desc(auditLogs.createdAt), desc(auditLogs.id)) + .limit(query.limit + 1) + + const hasMore = rows.length > query.limit + const page = rows.slice(0, query.limit) + return { + items: page.map(({ log, actor }) => ({ ...log, actor: actor?.id ? actor : null })), + nextCursor: hasMore ? page.at(-1)?.log.id ?? null : null, + } + }) +} diff --git a/server/src/routes/auth-security.integration.test.ts b/server/src/routes/auth-security.integration.test.ts new file mode 100644 index 0000000..89d0c63 --- /dev/null +++ b/server/src/routes/auth-security.integration.test.ts @@ -0,0 +1,214 @@ +import type { FastifyInstance } from 'fastify' +import { and, eq, isNull } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { auditLogs, refreshTokens, users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +interface AuthenticatedSession extends Session { + cookie: string +} + +function responseCookie(response: { headers: Record }) { + const header = response.headers['set-cookie'] + const value = Array.isArray(header) ? header[0] : String(header ?? '') + return value.split(';')[0] +} + +describe.sequential('account session security', () => { + let app: FastifyInstance + let primary: AuthenticatedSession + let secondary: AuthenticatedSession + let userId = '' + let workspaceId = '' + const initialPassword = 'Integration2026!' + const nextPassword = 'ChangedPassword2026!' + + async function inject(input: { + method: string + url: string + token?: string + cookie?: string + payload?: unknown + userAgent?: string + }) { + return app.inject({ + method: input.method, + url: input.url, + headers: { + ...(input.token ? { authorization: `Bearer ${input.token}` } : {}), + ...(input.cookie ? { cookie: input.cookie } : {}), + ...(input.userAgent ? { 'user-agent': input.userAgent } : {}), + }, + payload: input.payload, + }) + } + + async function login(password: string, userAgent: string): Promise { + const response = await inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: primary.user.email, password }, + userAgent, + }) + expect(response.statusCode, response.body).toBe(200) + return { ...response.json(), cookie: responseCookie(response) } + } + + beforeAll(async () => { + app = await buildApp() + const nonce = crypto.randomUUID().slice(0, 8) + const response = await inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `security-${nonce}@frameflow.test`, + password: initialPassword, + displayName: 'Security Test', + workspaceName: `Security Workspace ${nonce}`, + }, + userAgent: 'FrameFlow Desktop QA', + }) + expect(response.statusCode, response.body).toBe(201) + primary = { ...response.json(), cookie: responseCookie(response) } + userId = primary.user.id + workspaceId = primary.workspaces[0].id + secondary = await login(initialPassword, 'FrameFlow Mobile QA') + }) + + afterAll(async () => { + if (userId) await db.delete(auditLogs).where(eq(auditLogs.actorId, userId)) + if (workspaceId) await db.delete(workspaces).where(eq(workspaces.id, workspaceId)) + if (userId) await db.delete(users).where(eq(users.id, userId)) + await app.close() + }) + + it('lists active devices and identifies the refresh cookie session', async () => { + const unauthorized = await inject({ method: 'GET', url: '/api/v1/auth/sessions' }) + expect(unauthorized.statusCode, unauthorized.body).toBe(401) + + const response = await inject({ + method: 'GET', + url: '/api/v1/auth/sessions', + token: secondary.accessToken, + cookie: secondary.cookie, + }) + expect(response.statusCode, response.body).toBe(200) + const sessions = response.json>() + expect(sessions).toHaveLength(2) + expect(sessions.filter((session) => session.current)).toHaveLength(1) + expect(sessions.find((session) => session.current)?.userAgent).toBe('FrameFlow Mobile QA') + expect(sessions.some((session) => session.userAgent === 'FrameFlow Desktop QA')).toBe(true) + expect(sessions.every((session) => session.tokenHash === undefined)).toBe(true) + }) + + it('revokes a selected remote session without affecting the current device', async () => { + const list = await inject({ + method: 'GET', + url: '/api/v1/auth/sessions', + token: secondary.accessToken, + cookie: secondary.cookie, + }) + const remote = list.json>().find((session) => !session.current) + expect(remote).toBeTruthy() + + const revoked = await inject({ + method: 'DELETE', + url: `/api/v1/auth/sessions/${remote!.id}`, + token: secondary.accessToken, + cookie: secondary.cookie, + }) + expect(revoked.statusCode, revoked.body).toBe(204) + + const after = await inject({ + method: 'GET', + url: '/api/v1/auth/sessions', + token: secondary.accessToken, + cookie: secondary.cookie, + }) + expect(after.json()).toHaveLength(1) + + const rejectedRefresh = await inject({ method: 'POST', url: '/api/v1/auth/refresh', cookie: primary.cookie }) + expect(rejectedRefresh.statusCode, rejectedRefresh.body).toBe(401) + }) + + it('revokes every other device while preserving the current refresh session', async () => { + const tertiary = await login(initialPassword, 'FrameFlow Tablet QA') + const response = await inject({ + method: 'DELETE', + url: '/api/v1/auth/sessions/others', + token: secondary.accessToken, + cookie: secondary.cookie, + }) + expect(response.statusCode, response.body).toBe(200) + expect(response.json().revoked).toBe(1) + + const active = await inject({ + method: 'GET', + url: '/api/v1/auth/sessions', + token: secondary.accessToken, + cookie: secondary.cookie, + }) + expect(active.json()).toEqual([expect.objectContaining({ current: true, userAgent: 'FrameFlow Mobile QA' })]) + + const rejectedRefresh = await inject({ method: 'POST', url: '/api/v1/auth/refresh', cookie: tertiary.cookie }) + expect(rejectedRefresh.statusCode, rejectedRefresh.body).toBe(401) + }) + + it('changes the password only after verification and invalidates all old sessions', async () => { + const remote = await login(initialPassword, 'FrameFlow Studio QA') + const wrong = await inject({ + method: 'PATCH', + url: '/api/v1/auth/password', + token: secondary.accessToken, + cookie: secondary.cookie, + payload: { currentPassword: 'WrongPassword2026!', newPassword: nextPassword }, + }) + expect(wrong.statusCode, wrong.body).toBe(401) + + const reused = await inject({ + method: 'PATCH', + url: '/api/v1/auth/password', + token: secondary.accessToken, + cookie: secondary.cookie, + payload: { currentPassword: initialPassword, newPassword: initialPassword }, + }) + expect(reused.statusCode, reused.body).toBe(409) + + const changed = await inject({ + method: 'PATCH', + url: '/api/v1/auth/password', + token: secondary.accessToken, + cookie: secondary.cookie, + payload: { currentPassword: initialPassword, newPassword: nextPassword }, + }) + expect(changed.statusCode, changed.body).toBe(204) + expect(String(changed.headers['set-cookie'])).toContain('ff_refresh=') + + const [activeCount] = await db.select({ id: refreshTokens.id }).from(refreshTokens).where(and( + eq(refreshTokens.userId, userId), + isNull(refreshTokens.revokedAt), + )).limit(1) + expect(activeCount).toBeUndefined() + + for (const cookie of [secondary.cookie, remote.cookie]) { + const rejectedRefresh = await inject({ method: 'POST', url: '/api/v1/auth/refresh', cookie }) + expect(rejectedRefresh.statusCode, rejectedRefresh.body).toBe(401) + } + + const oldLogin = await inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: primary.user.email, password: initialPassword }, + }) + expect(oldLogin.statusCode, oldLogin.body).toBe(401) + const newLogin = await login(nextPassword, 'FrameFlow New Password QA') + expect(newLogin.accessToken).toBeTruthy() + }) +}) diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..791d26f --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,448 @@ +import argon2 from 'argon2' +import type { FastifyInstance, FastifyRequest } from 'fastify' +import { and, desc, eq, gt, isNull, ne } from 'drizzle-orm' +import { z } from 'zod' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { accountActionTokens, memberships, refreshTokens, users, workspaces } from '../db/schema.js' +import { + createRefreshSession, + hashToken, + REFRESH_COOKIE, + refreshCookieOptions, + requireAuth, + revokeRefreshSession, + rotateRefreshSession, + signAccessToken, +} from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { issueAccountToken, type AccountTokenType } from '../lib/account-tokens.js' +import { errors } from '../lib/errors.js' +import { parseWith, requestMeta } from '../lib/http.js' +import { accountActionUrl, sendAccountActionEmail } from '../lib/mailer.js' +import { slugify } from '../lib/slug.js' + +const registerSchema = z.object({ + email: z.email().max(320), + password: z.string().min(10).max(128), + displayName: z.string().trim().min(2).max(120), + workspaceName: z.string().trim().min(2).max(160), +}) + +const loginSchema = z.object({ + email: z.email().max(320), + password: z.string().min(1).max(128), +}) +const sessionParams = z.object({ sessionId: z.uuid() }) +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1).max(128), + newPassword: z.string().min(10).max(128), +}) +const emailActionRequestSchema = z.object({ email: z.email().max(320) }) +const confirmTokenSchema = z.object({ token: z.string().min(32).max(256) }) +const resetPasswordSchema = z.object({ + token: z.string().min(32).max(256), + newPassword: z.string().min(10).max(128), +}) + +function publicUser(user: { id: string; email: string; displayName: string; avatarKey?: string | null; emailVerifiedAt?: Date | null }) { + return { + id: user.id, + email: user.email, + displayName: user.displayName, + avatarKey: user.avatarKey ?? null, + emailVerified: Boolean(user.emailVerifiedAt), + } +} + +async function deliverAccountAction(request: FastifyRequest, input: { + user: { id: string; email: string; displayName: string } + type: AccountTokenType +}) { + const issued = await issueAccountToken(input.user.id, input.type) + const url = accountActionUrl(input.type, issued.token) + let sent = false + try { + const delivery = await sendAccountActionEmail({ + type: input.type, + to: input.user.email, + displayName: input.user.displayName, + url, + }) + sent = delivery.sent + } catch (error) { + request.log.error({ err: error, userId: input.user.id, type: input.type }, 'Account email delivery failed') + } + return { + sent, + ...(config.NODE_ENV !== 'production' ? { debugUrl: url } : {}), + } +} + +export async function authRoutes(app: FastifyInstance) { + app.post('/register', { + config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, + }, async (request, reply) => { + const input = parseWith(registerSchema, request.body) + const email = input.email.trim().toLowerCase() + const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1) + if (existing) throw errors.conflict('An account with this email already exists') + + const passwordHash = await argon2.hash(input.password, { type: argon2.argon2id }) + const workspaceSlug = `${slugify(input.workspaceName)}-${crypto.randomUUID().slice(0, 6)}` + + const result = await db.transaction(async (tx) => { + const [user] = await tx.insert(users).values({ + email, + displayName: input.displayName, + passwordHash, + emailVerifiedAt: config.EMAIL_VERIFICATION_REQUIRED ? null : new Date(), + }).returning() + if (!user) throw new Error('Failed to create user') + + const [workspace] = await tx.insert(workspaces).values({ + name: input.workspaceName, + slug: workspaceSlug, + ownerId: user.id, + }).returning() + if (!workspace) throw new Error('Failed to create workspace') + + await tx.insert(memberships).values({ workspaceId: workspace.id, userId: user.id, role: 'OWNER' }) + return { user, workspace } + }) + + request.user = { + sub: result.user.id, + email: result.user.email, + displayName: result.user.displayName, + tokenType: 'access', + } + await audit({ + request, + workspaceId: result.workspace.id, + action: 'auth.register', + resourceType: 'user', + resourceId: result.user.id, + after: { email: result.user.email, workspaceId: result.workspace.id }, + }) + + if (config.EMAIL_VERIFICATION_REQUIRED) { + const delivery = await deliverAccountAction(request, { user: result.user, type: 'EMAIL_VERIFICATION' }) + return reply.code(201).send({ + verificationRequired: true, + emailSent: delivery.sent, + user: publicUser(result.user), + ...(delivery.debugUrl ? { debugVerificationUrl: delivery.debugUrl } : {}), + }) + } + + const meta = requestMeta(request) + const refresh = await createRefreshSession({ + userId: result.user.id, + ipAddress: meta.ipAddress, + ...(meta.userAgent ? { userAgent: meta.userAgent } : {}), + }) + reply.setCookie(REFRESH_COOKIE, refresh.token, refreshCookieOptions()) + + return reply.code(201).send({ + accessToken: signAccessToken(app, result.user), + user: publicUser(result.user), + workspaces: [{ id: result.workspace.id, name: result.workspace.name, slug: result.workspace.slug, role: 'OWNER' }], + }) + }) + + app.post('/login', { + config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, + }, async (request, reply) => { + const input = parseWith(loginSchema, request.body) + const email = input.email.trim().toLowerCase() + const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1) + if (!user || user.disabledAt || !(await argon2.verify(user.passwordHash, input.password))) { + throw errors.unauthorized('Email or password is incorrect') + } + if (config.EMAIL_VERIFICATION_REQUIRED && !user.emailVerifiedAt) throw errors.emailVerificationRequired() + + const meta = requestMeta(request) + const refresh = await createRefreshSession({ + userId: user.id, + ipAddress: meta.ipAddress, + ...(meta.userAgent ? { userAgent: meta.userAgent } : {}), + }) + reply.setCookie(REFRESH_COOKIE, refresh.token, refreshCookieOptions()) + + const workspaceRows = await db.select({ + id: workspaces.id, + name: workspaces.name, + slug: workspaces.slug, + role: memberships.role, + }).from(memberships).innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId)).where(eq(memberships.userId, user.id)) + + return { + accessToken: signAccessToken(app, user), + user: publicUser(user), + workspaces: workspaceRows, + } + }) + + app.post('/refresh', async (request, reply) => { + const current = request.cookies[REFRESH_COOKIE] + if (!current) throw errors.unauthorized('Refresh cookie is missing') + const meta = requestMeta(request) + const rotated = await rotateRefreshSession(current, { + ipAddress: meta.ipAddress, + ...(meta.userAgent ? { userAgent: meta.userAgent } : {}), + }) + reply.setCookie(REFRESH_COOKIE, rotated.token, refreshCookieOptions()) + const workspaceRows = await db.select({ + id: workspaces.id, + name: workspaces.name, + slug: workspaces.slug, + role: memberships.role, + }).from(memberships).innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId)).where(eq(memberships.userId, rotated.user.id)) + return { accessToken: signAccessToken(app, rotated.user), user: publicUser(rotated.user), workspaces: workspaceRows } + }) + + app.post('/logout', async (request, reply) => { + await revokeRefreshSession(request.cookies[REFRESH_COOKIE]) + reply.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }) + return reply.code(204).send() + }) + + app.post('/verify-email/request', { + config: { rateLimit: { max: 5, timeWindow: '10 minutes' } }, + }, async (request, reply) => { + const input = parseWith(emailActionRequestSchema, request.body) + const email = input.email.trim().toLowerCase() + const [user] = await db.select({ + id: users.id, + email: users.email, + displayName: users.displayName, + emailVerifiedAt: users.emailVerifiedAt, + disabledAt: users.disabledAt, + }).from(users).where(eq(users.email, email)).limit(1) + let debugVerificationUrl: string | undefined + if (user && !user.disabledAt && !user.emailVerifiedAt) { + const delivery = await deliverAccountAction(request, { user, type: 'EMAIL_VERIFICATION' }) + debugVerificationUrl = delivery.debugUrl + } + return reply.code(202).send({ + accepted: true, + message: 'If the account can be verified, an email has been sent', + ...(debugVerificationUrl ? { debugVerificationUrl } : {}), + }) + }) + + app.post('/verify-email/confirm', { + config: { rateLimit: { max: 10, timeWindow: '10 minutes' } }, + }, async (request) => { + const input = parseWith(confirmTokenSchema, request.body) + const now = new Date() + const result = await db.transaction(async (tx) => { + const tokenHash = hashToken(input.token) + const [row] = await tx.select({ token: accountActionTokens, user: users }).from(accountActionTokens) + .innerJoin(users, eq(users.id, accountActionTokens.userId)) + .where(and( + eq(accountActionTokens.tokenHash, tokenHash), + eq(accountActionTokens.type, 'EMAIL_VERIFICATION'), + isNull(accountActionTokens.consumedAt), + gt(accountActionTokens.expiresAt, now), + )).limit(1) + if (!row || row.user.disabledAt) throw errors.badRequest('验证链接无效或已过期') + const [consumed] = await tx.update(accountActionTokens).set({ consumedAt: now }).where(and( + eq(accountActionTokens.id, row.token.id), + isNull(accountActionTokens.consumedAt), + )).returning({ id: accountActionTokens.id }) + if (!consumed) throw errors.badRequest('验证链接无效或已过期') + const [user] = await tx.update(users).set({ emailVerifiedAt: row.user.emailVerifiedAt ?? now, updatedAt: now }) + .where(eq(users.id, row.user.id)).returning() + if (!user) throw errors.notFound('User') + return user + }) + await audit({ + request, + action: 'auth.email.verify', + resourceType: 'user', + resourceId: result.id, + after: { emailVerified: true }, + }) + return { verified: true, user: publicUser(result) } + }) + + app.post('/password/forgot', { + config: { rateLimit: { max: 5, timeWindow: '10 minutes' } }, + }, async (request, reply) => { + const input = parseWith(emailActionRequestSchema, request.body) + const email = input.email.trim().toLowerCase() + const [user] = await db.select({ + id: users.id, + email: users.email, + displayName: users.displayName, + disabledAt: users.disabledAt, + }).from(users).where(eq(users.email, email)).limit(1) + let debugResetUrl: string | undefined + if (user && !user.disabledAt) { + const delivery = await deliverAccountAction(request, { user, type: 'PASSWORD_RESET' }) + debugResetUrl = delivery.debugUrl + } + return reply.code(202).send({ + accepted: true, + message: 'If the account exists, a password reset email has been sent', + ...(debugResetUrl ? { debugResetUrl } : {}), + }) + }) + + app.post('/password/reset', { + config: { rateLimit: { max: 10, timeWindow: '10 minutes' } }, + }, async (request, reply) => { + const input = parseWith(resetPasswordSchema, request.body) + const now = new Date() + const passwordHash = await argon2.hash(input.newPassword, { type: argon2.argon2id }) + const result = await db.transaction(async (tx) => { + const tokenHash = hashToken(input.token) + const [row] = await tx.select({ token: accountActionTokens, user: users }).from(accountActionTokens) + .innerJoin(users, eq(users.id, accountActionTokens.userId)) + .where(and( + eq(accountActionTokens.tokenHash, tokenHash), + eq(accountActionTokens.type, 'PASSWORD_RESET'), + isNull(accountActionTokens.consumedAt), + gt(accountActionTokens.expiresAt, now), + )).limit(1) + if (!row || row.user.disabledAt) throw errors.badRequest('密码重置链接无效或已过期') + if (await argon2.verify(row.user.passwordHash, input.newPassword)) throw errors.conflict('新密码不能与当前密码相同') + const [consumed] = await tx.update(accountActionTokens).set({ consumedAt: now }).where(and( + eq(accountActionTokens.id, row.token.id), + isNull(accountActionTokens.consumedAt), + )).returning({ id: accountActionTokens.id }) + if (!consumed) throw errors.badRequest('密码重置链接无效或已过期') + await tx.update(users).set({ passwordHash, updatedAt: now }).where(eq(users.id, row.user.id)) + const revoked = await tx.update(refreshTokens).set({ revokedAt: now }).where(and( + eq(refreshTokens.userId, row.user.id), + isNull(refreshTokens.revokedAt), + )).returning({ id: refreshTokens.id }) + return { user: row.user, revokedSessionCount: revoked.length } + }) + reply.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }) + await audit({ + request, + action: 'auth.password.reset', + resourceType: 'user', + resourceId: result.user.id, + after: { revokedSessionCount: result.revokedSessionCount }, + }) + return reply.code(204).send() + }) + + app.get('/sessions', { preHandler: requireAuth }, async (request) => { + const currentHash = request.cookies[REFRESH_COOKIE] ? hashToken(request.cookies[REFRESH_COOKIE]) : null + const sessions = await db.select({ + id: refreshTokens.id, + tokenHash: refreshTokens.tokenHash, + userAgent: refreshTokens.userAgent, + ipAddress: refreshTokens.ipAddress, + createdAt: refreshTokens.createdAt, + expiresAt: refreshTokens.expiresAt, + }).from(refreshTokens).where(and( + eq(refreshTokens.userId, request.user.sub), + isNull(refreshTokens.revokedAt), + gt(refreshTokens.expiresAt, new Date()), + )).orderBy(desc(refreshTokens.createdAt)) + + return sessions.map(({ tokenHash, ...session }) => ({ + ...session, + current: currentHash === tokenHash, + })) + }) + + app.delete('/sessions/others', { preHandler: requireAuth }, async (request) => { + const currentHash = request.cookies[REFRESH_COOKIE] ? hashToken(request.cookies[REFRESH_COOKIE]) : null + const filters = [ + eq(refreshTokens.userId, request.user.sub), + isNull(refreshTokens.revokedAt), + gt(refreshTokens.expiresAt, new Date()), + ] + if (currentHash) filters.push(ne(refreshTokens.tokenHash, currentHash)) + const revoked = await db.update(refreshTokens).set({ revokedAt: new Date() }).where(and(...filters)).returning({ id: refreshTokens.id }) + await audit({ + request, + action: 'auth.sessions.revoke_others', + resourceType: 'user', + resourceId: request.user.sub, + after: { revokedCount: revoked.length }, + }) + return { revoked: revoked.length } + }) + + app.delete('/sessions/:sessionId', { preHandler: requireAuth }, async (request, reply) => { + const { sessionId } = parseWith(sessionParams, request.params) + const [session] = await db.select().from(refreshTokens).where(and( + eq(refreshTokens.id, sessionId), + eq(refreshTokens.userId, request.user.sub), + )).limit(1) + if (!session) throw errors.notFound('Session') + if (!session.revokedAt) await db.update(refreshTokens).set({ revokedAt: new Date() }).where(eq(refreshTokens.id, session.id)) + + const current = request.cookies[REFRESH_COOKIE] + if (current && hashToken(current) === session.tokenHash) reply.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }) + await audit({ + request, + action: 'auth.session.revoke', + resourceType: 'refresh_session', + resourceId: session.id, + before: { revoked: Boolean(session.revokedAt) }, + after: { revoked: true }, + }) + return reply.code(204).send() + }) + + app.patch('/password', { + preHandler: requireAuth, + config: { rateLimit: { max: 5, timeWindow: '1 minute' } }, + }, async (request, reply) => { + const input = parseWith(changePasswordSchema, request.body) + const [user] = await db.select().from(users).where(eq(users.id, request.user.sub)).limit(1) + if (!user || user.disabledAt) throw errors.unauthorized('Account is unavailable') + if (!(await argon2.verify(user.passwordHash, input.currentPassword))) throw errors.unauthorized('Current password is incorrect') + if (await argon2.verify(user.passwordHash, input.newPassword)) throw errors.conflict('New password must be different from the current password') + + const passwordHash = await argon2.hash(input.newPassword, { type: argon2.argon2id }) + const revokedAt = new Date() + const revokedCount = await db.transaction(async (tx) => { + await tx.update(users).set({ passwordHash, updatedAt: revokedAt }).where(eq(users.id, user.id)) + const revoked = await tx.update(refreshTokens).set({ revokedAt }).where(and( + eq(refreshTokens.userId, user.id), + isNull(refreshTokens.revokedAt), + )).returning({ id: refreshTokens.id }) + return revoked.length + }) + reply.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }) + await audit({ + request, + action: 'auth.password.change', + resourceType: 'user', + resourceId: user.id, + after: { revokedSessionCount: revokedCount }, + }) + return reply.code(204).send() + }) + + app.get('/me', { preHandler: requireAuth }, async (request) => { + const [user] = await db.select({ + id: users.id, + email: users.email, + displayName: users.displayName, + avatarKey: users.avatarKey, + emailVerifiedAt: users.emailVerifiedAt, + }).from(users).where(and(eq(users.id, request.user.sub))).limit(1) + if (!user) throw errors.notFound('User') + + const workspaceRows = await db.select({ + id: workspaces.id, + name: workspaces.name, + slug: workspaces.slug, + plan: workspaces.plan, + role: memberships.role, + }).from(memberships).innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId)).where(eq(memberships.userId, user.id)) + + return { user: publicUser(user), workspaces: workspaceRows } + }) +} diff --git a/server/src/routes/billing.integration.test.ts b/server/src/routes/billing.integration.test.ts new file mode 100644 index 0000000..ba316b1 --- /dev/null +++ b/server/src/routes/billing.integration.test.ts @@ -0,0 +1,355 @@ +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import type Stripe from 'stripe' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { + billingSubscriptions, + billingWebhookEvents, + users, + workspaces, +} from '../db/schema.js' +import type { + BillingProviderReadiness, + StripeBillingProvider, +} from '../lib/stripe-billing.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +function subscription(input: { + id?: string + customerId?: string + workspaceId: string + priceId?: string + status?: Stripe.Subscription.Status + periodEnd?: number + cancelAtPeriodEnd?: boolean +}) { + return { + id: input.id ?? 'sub_frameflow', + object: 'subscription', + customer: input.customerId ?? 'cus_frameflow', + metadata: { frameflowWorkspaceId: input.workspaceId }, + status: input.status ?? 'active', + cancel_at_period_end: input.cancelAtPeriodEnd ?? false, + items: { + data: [{ + price: { id: input.priceId ?? 'price_studio' }, + current_period_end: input.periodEnd ?? 1_800_000_000, + }], + }, + } as unknown as Stripe.Subscription +} + +function stripeEvent(input: { + id: string + type: Stripe.Event.Type + object: object + created: number +}) { + return { + id: input.id, + object: 'event', + api_version: null, + created: input.created, + data: { object: input.object }, + livemode: false, + pending_webhooks: 1, + request: null, + type: input.type, + } as unknown as Stripe.Event +} + +class FakeStripeBillingProvider implements StripeBillingProvider { + readonly name = 'stripe' as const + configured = true + customerCalls: Array> = [] + checkoutCalls: Array> = [] + portalCalls: Array> = [] + retrieveCalls: string[] = [] + subscriptions = new Map() + + readiness(): BillingProviderReadiness { + const missingRequirements = this.configured ? [] : ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'] + return { + provider: 'stripe', + status: this.configured ? 'CONFIGURED' : 'NOT_CONFIGURED', + portalConfigured: this.configured, + webhookConfigured: this.configured, + missingRequirements, + plans: { + PRO: { checkoutAvailable: this.configured, missingRequirements }, + STUDIO: { checkoutAvailable: this.configured, missingRequirements }, + }, + } + } + + planForPriceId(priceId: string) { + if (priceId === 'price_pro') return 'PRO' as const + if (priceId === 'price_studio') return 'STUDIO' as const + return null + } + + async createCustomer(input: { + workspaceId: string + workspaceName: string + email: string + displayName: string + }) { + this.customerCalls.push(input) + return { id: 'cus_frameflow' } + } + + async createCheckoutSession(input: { + workspaceId: string + customerId: string + plan: 'PRO' | 'STUDIO' + idempotencyKey: string + }) { + this.checkoutCalls.push(input) + return { id: `cs_${this.checkoutCalls.length}`, url: 'https://checkout.stripe.test/session' } + } + + async createPortalSession(input: { customerId: string }) { + this.portalCalls.push(input) + return { id: 'bps_frameflow', url: 'https://billing.stripe.test/portal' } + } + + constructWebhookEvent(payload: Buffer, signature: string) { + if (signature !== 'valid-signature') throw new Error('invalid signature') + return JSON.parse(payload.toString('utf8')) as Stripe.Event + } + + async retrieveSubscription(subscriptionId: string) { + this.retrieveCalls.push(subscriptionId) + const row = this.subscriptions.get(subscriptionId) + if (!row) throw new Error(`Missing fake subscription ${subscriptionId}`) + return row + } +} + +describe.sequential('Stripe workspace billing', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + const eventIds: string[] = [] + const provider = new FakeStripeBillingProvider() + + async function inject(token: string | null, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: token ? { authorization: `Bearer ${token}` } : undefined, + payload, + }) + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + async function sendWebhook(event: Stripe.Event, signature = 'valid-signature') { + eventIds.push(event.id) + return app.inject({ + method: 'POST', + url: '/api/v1/billing/webhooks/stripe', + headers: { + 'content-type': 'application/json', + 'stripe-signature': signature, + }, + payload: JSON.stringify(event), + }) + } + + beforeAll(async () => { + app = await buildApp({ billingProvider: provider }) + owner = await registerAccount('billing-owner') + viewer = await registerAccount('billing-viewer') + workspaceId = owner.workspaces[0].id + const memberResponse = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + expect(memberResponse.statusCode, memberResponse.body).toBe(201) + }) + + afterAll(async () => { + if (eventIds.length) await db.delete(billingWebhookEvents).where(inArray(billingWebhookEvents.eventId, eventIds)) + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('reports provider and plan availability without exposing Stripe identifiers', async () => { + const response = await inject(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/billing/summary`) + expect(response.statusCode, response.body).toBe(200) + const summary = response.json() + expect(summary.canManage).toBe(true) + expect(summary.provider).toMatchObject({ name: 'stripe', status: 'CONFIGURED', configured: true }) + expect(summary.plans).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'PRO', monthlyCreditLimit: 10_000, checkoutAvailable: true }), + expect.objectContaining({ id: 'STUDIO', monthlyCreditLimit: 50_000, checkoutAvailable: true }), + expect.objectContaining({ id: 'ENTERPRISE', checkoutAvailable: false }), + ])) + expect(summary.subscription).toBeNull() + expect(response.body).not.toContain('price_studio') + }) + + it('requires billing management permission and reports missing provider configuration', async () => { + const forbidden = await inject(viewer.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/checkout`, { + plan: 'STUDIO', + requestId: crypto.randomUUID(), + }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + provider.configured = false + const unavailable = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/checkout`, { + plan: 'STUDIO', + requestId: crypto.randomUUID(), + }) + expect(unavailable.statusCode, unavailable.body).toBe(503) + expect(unavailable.json().error.details.missingRequirements).toContain('STRIPE_SECRET_KEY') + provider.configured = true + }) + + it('creates one customer, reuses it, and sends stable Checkout idempotency keys', async () => { + const firstRequestId = crypto.randomUUID() + const first = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/checkout`, { + plan: 'STUDIO', + requestId: firstRequestId, + }) + expect(first.statusCode, first.body).toBe(200) + expect(first.json().url).toBe('https://checkout.stripe.test/session') + + const second = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/checkout`, { + plan: 'PRO', + requestId: crypto.randomUUID(), + }) + expect(second.statusCode, second.body).toBe(200) + expect(provider.customerCalls).toHaveLength(1) + expect(provider.customerCalls[0]).toMatchObject({ workspaceId, email: owner.user.email }) + expect(provider.checkoutCalls).toHaveLength(2) + expect(provider.checkoutCalls[0]).toMatchObject({ + workspaceId, + customerId: 'cus_frameflow', + plan: 'STUDIO', + idempotencyKey: `frameflow-checkout:${workspaceId}:${firstRequestId}`, + }) + }) + + it('verifies, deduplicates, and applies subscription webhooks atomically', async () => { + const invalidEvent = stripeEvent({ + id: 'evt_invalid_signature', + type: 'checkout.session.completed', + created: 1_799_999_900, + object: { mode: 'subscription', subscription: 'sub_frameflow' }, + }) + const invalid = await sendWebhook(invalidEvent, 'invalid-signature') + expect(invalid.statusCode, invalid.body).toBe(400) + + provider.subscriptions.set('sub_frameflow', subscription({ workspaceId })) + const event = stripeEvent({ + id: 'evt_checkout_completed', + type: 'checkout.session.completed', + created: 1_800_000_000, + object: { mode: 'subscription', subscription: 'sub_frameflow' }, + }) + const applied = await sendWebhook(event) + expect(applied.statusCode, applied.body).toBe(200) + expect(applied.json()).toEqual({ received: true, duplicate: false }) + + const duplicate = await sendWebhook(event) + expect(duplicate.statusCode, duplicate.body).toBe(200) + expect(duplicate.json()).toEqual({ received: true, duplicate: true }) + expect(provider.retrieveCalls.filter((id) => id === 'sub_frameflow')).toHaveLength(1) + + const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) + const [billing] = await db.select().from(billingSubscriptions).where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1) + expect(workspace).toMatchObject({ plan: 'STUDIO', monthlyCreditLimit: 50_000 }) + expect(billing).toMatchObject({ + customerId: 'cus_frameflow', + subscriptionId: 'sub_frameflow', + priceId: 'price_studio', + plan: 'STUDIO', + status: 'active', + cancelAtPeriodEnd: false, + }) + }) + + it('routes active subscribers to the portal and blocks a second subscription', async () => { + const duplicateCheckout = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/checkout`, { + plan: 'PRO', + requestId: crypto.randomUUID(), + }) + expect(duplicateCheckout.statusCode, duplicateCheckout.body).toBe(409) + + const portal = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/billing/portal`) + expect(portal.statusCode, portal.body).toBe(200) + expect(portal.json().url).toBe('https://billing.stripe.test/portal') + expect(provider.portalCalls).toEqual([{ customerId: 'cus_frameflow' }]) + }) + + it('downgrades canceled subscriptions and ignores older deliveries', async () => { + const cancelled = subscription({ + workspaceId, + status: 'canceled', + cancelAtPeriodEnd: true, + periodEnd: 1_800_100_000, + }) + const deletedEvent = stripeEvent({ + id: 'evt_subscription_deleted', + type: 'customer.subscription.deleted', + created: 1_800_000_200, + object: cancelled, + }) + const deleted = await sendWebhook(deletedEvent) + expect(deleted.statusCode, deleted.body).toBe(200) + + provider.subscriptions.set('sub_frameflow', subscription({ workspaceId, status: 'active' })) + const staleEvent = stripeEvent({ + id: 'evt_subscription_stale', + type: 'customer.subscription.updated', + created: 1_800_000_100, + object: subscription({ workspaceId, status: 'active' }), + }) + const stale = await sendWebhook(staleEvent) + expect(stale.statusCode, stale.body).toBe(200) + + const [workspace] = await db.select().from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1) + const [billing] = await db.select().from(billingSubscriptions).where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1) + expect(workspace).toMatchObject({ plan: 'PRO', monthlyCreditLimit: 10_000 }) + expect(billing).toMatchObject({ status: 'canceled', plan: 'STUDIO', cancelAtPeriodEnd: true }) + + const summaryResponse = await inject(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/billing/summary`) + expect(summaryResponse.statusCode, summaryResponse.body).toBe(200) + expect(summaryResponse.json().subscription).toMatchObject({ + status: 'canceled', + plan: 'STUDIO', + hasSubscription: true, + cancelAtPeriodEnd: true, + }) + }) +}) diff --git a/server/src/routes/billing.ts b/server/src/routes/billing.ts new file mode 100644 index 0000000..e2081cc --- /dev/null +++ b/server/src/routes/billing.ts @@ -0,0 +1,320 @@ +import type { FastifyInstance } from 'fastify' +import { and, eq, or, sql } from 'drizzle-orm' +import type Stripe from 'stripe' +import { z } from 'zod' +import { db } from '../db/client.js' +import { + billingSubscriptions, + billingWebhookEvents, + users, + workspaces, +} from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { workspaceBillingSummary } from '../lib/billing.js' +import { + DEFAULT_BILLING_PLAN, + planCreditLimit, + type PurchasableBillingPlan, +} from '../lib/billing-plans.js' +import { AppError, errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { can, requireWorkspacePermission } from '../lib/permissions.js' +import { + createStripeBillingProvider, + type StripeBillingProvider, +} from '../lib/stripe-billing.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const checkoutBody = z.object({ + plan: z.enum(['PRO', 'STUDIO']), + requestId: z.uuid(), +}) +const terminalSubscriptionStatuses = new Set(['canceled', 'incomplete_expired']) +const entitledSubscriptionStatuses = new Set(['active', 'trialing', 'past_due']) + +interface BillingRouteOptions { + billingProvider?: StripeBillingProvider +} + +function stripeObjectId(value: string | { id: string } | null) { + return typeof value === 'string' ? value : value?.id ?? null +} + +function subscriptionSnapshot(provider: StripeBillingProvider, subscription: Stripe.Subscription) { + const item = subscription.items.data.find((candidate) => provider.planForPriceId(candidate.price.id)) + ?? subscription.items.data[0] + const priceId = item?.price.id ?? null + return { + customerId: stripeObjectId(subscription.customer), + subscriptionId: subscription.id, + priceId, + plan: priceId ? provider.planForPriceId(priceId) : null, + status: subscription.status, + currentPeriodEnd: item?.current_period_end ? new Date(item.current_period_end * 1000) : null, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + } +} + +function metadataWorkspaceId(subscription: Stripe.Subscription) { + const parsed = z.uuid().safeParse(subscription.metadata.frameflowWorkspaceId) + return parsed.success ? parsed.data : null +} + +async function ensureBillingCustomer(provider: StripeBillingProvider, workspaceId: string) { + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`billing-customer:${workspaceId}`}))`) + const [existing] = await tx.select().from(billingSubscriptions) + .where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1) + if (existing) return existing + + const [workspace] = await tx.select({ + id: workspaces.id, + name: workspaces.name, + ownerEmail: users.email, + ownerDisplayName: users.displayName, + }).from(workspaces) + .innerJoin(users, eq(users.id, workspaces.ownerId)) + .where(eq(workspaces.id, workspaceId)).limit(1) + if (!workspace) throw errors.notFound('Workspace') + + const customer = await provider.createCustomer({ + workspaceId, + workspaceName: workspace.name, + email: workspace.ownerEmail, + displayName: workspace.ownerDisplayName, + }) + const [created] = await tx.insert(billingSubscriptions).values({ + workspaceId, + provider: provider.name, + customerId: customer.id, + }).returning() + if (!created) throw new Error('Failed to persist billing customer') + return created + }) +} + +async function existingWebhookEvent(eventId: string) { + const [existing] = await db.select({ eventId: billingWebhookEvents.eventId }) + .from(billingWebhookEvents).where(eq(billingWebhookEvents.eventId, eventId)).limit(1) + return Boolean(existing) +} + +async function subscriptionFromEvent(provider: StripeBillingProvider, event: Stripe.Event) { + if (event.type === 'checkout.session.completed') { + const session = event.data.object as Stripe.Checkout.Session + if (session.mode !== 'subscription') return null + const subscriptionId = stripeObjectId(session.subscription) + return subscriptionId ? provider.retrieveSubscription(subscriptionId) : null + } + if (event.type === 'customer.subscription.created' + || event.type === 'customer.subscription.updated' + || event.type === 'customer.subscription.deleted') { + const eventSubscription = event.data.object as Stripe.Subscription + if (event.type === 'customer.subscription.deleted') return eventSubscription + return provider.retrieveSubscription(eventSubscription.id) + } + return null +} + +export async function processStripeWebhookEvent(provider: StripeBillingProvider, event: Stripe.Event) { + if (await existingWebhookEvent(event.id)) return { duplicate: true, workspaceId: null, subscription: null } + const subscription = await subscriptionFromEvent(provider, event) + const snapshot = subscription ? subscriptionSnapshot(provider, subscription) : null + const eventCreatedAt = new Date(event.created * 1000) + + return db.transaction(async (tx) => { + const [inserted] = await tx.insert(billingWebhookEvents).values({ + eventId: event.id, + provider: provider.name, + eventType: event.type, + livemode: event.livemode, + providerCreatedAt: eventCreatedAt, + }).onConflictDoNothing().returning({ eventId: billingWebhookEvents.eventId }) + if (!inserted) return { duplicate: true, workspaceId: null, subscription: null } + if (!subscription || !snapshot?.customerId) { + return { duplicate: false, workspaceId: null, subscription: null } + } + + const metadataId = metadataWorkspaceId(subscription) + let [account] = await tx.select().from(billingSubscriptions).where(or( + eq(billingSubscriptions.customerId, snapshot.customerId), + eq(billingSubscriptions.subscriptionId, snapshot.subscriptionId), + )).limit(1) + if (!account && metadataId) { + ;[account] = await tx.select().from(billingSubscriptions) + .where(eq(billingSubscriptions.workspaceId, metadataId)).limit(1) + } + + let workspaceId = account?.workspaceId ?? metadataId + if (!workspaceId) return { duplicate: false, workspaceId: null, subscription: null } + const [workspace] = await tx.select({ id: workspaces.id }).from(workspaces) + .where(eq(workspaces.id, workspaceId)).limit(1) + if (!workspace) return { duplicate: false, workspaceId: null, subscription: null } + + if (account && account.customerId !== snapshot.customerId) { + throw errors.conflict('Stripe customer does not match the workspace billing account') + } + if (account?.lastEventCreatedAt && account.lastEventCreatedAt > eventCreatedAt) { + await tx.update(billingWebhookEvents).set({ workspaceId }).where(eq(billingWebhookEvents.eventId, event.id)) + return { duplicate: false, workspaceId, subscription: null } + } + + const subscriptionValues = { + provider: provider.name, + customerId: snapshot.customerId, + subscriptionId: snapshot.subscriptionId, + priceId: snapshot.priceId, + plan: snapshot.plan, + status: snapshot.status, + currentPeriodEnd: snapshot.currentPeriodEnd, + cancelAtPeriodEnd: snapshot.cancelAtPeriodEnd, + lastEventCreatedAt: eventCreatedAt, + updatedAt: new Date(), + } + if (account) { + await tx.update(billingSubscriptions).set(subscriptionValues) + .where(eq(billingSubscriptions.id, account.id)) + } else { + const [created] = await tx.insert(billingSubscriptions).values({ + workspaceId, + ...subscriptionValues, + }).returning() + if (!created) throw new Error('Failed to persist Stripe subscription') + account = created + } + + let appliedPlan: string | null = null + if (snapshot.plan && entitledSubscriptionStatuses.has(snapshot.status)) { + appliedPlan = snapshot.plan + await tx.update(workspaces).set({ + plan: snapshot.plan, + monthlyCreditLimit: planCreditLimit(snapshot.plan), + updatedAt: new Date(), + }).where(eq(workspaces.id, workspaceId)) + } else if (!entitledSubscriptionStatuses.has(snapshot.status)) { + appliedPlan = DEFAULT_BILLING_PLAN + await tx.update(workspaces).set({ + plan: DEFAULT_BILLING_PLAN, + monthlyCreditLimit: planCreditLimit(DEFAULT_BILLING_PLAN), + updatedAt: new Date(), + }).where(eq(workspaces.id, workspaceId)) + } + + await tx.update(billingWebhookEvents).set({ workspaceId }) + .where(eq(billingWebhookEvents.eventId, event.id)) + return { + duplicate: false, + workspaceId, + subscription: { + id: snapshot.subscriptionId, + status: snapshot.status, + plan: snapshot.plan, + appliedPlan, + }, + } + }) +} + +export async function billingRoutes(app: FastifyInstance, options: BillingRouteOptions) { + const provider = options.billingProvider ?? createStripeBillingProvider() + + app.get('/workspaces/:workspaceId/billing/summary', { preHandler: requireAuth }, async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const membership = await requireWorkspacePermission(request, workspaceId, 'billing:read') + const summary = await workspaceBillingSummary(workspaceId, { + providerReadiness: provider.readiness(), + canManage: can(membership.role, 'billing:manage'), + }) + if (!summary) throw errors.notFound('Workspace') + return summary + }) + + app.post('/workspaces/:workspaceId/billing/checkout', { preHandler: requireAuth }, async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(checkoutBody, request.body) + await requireWorkspacePermission(request, workspaceId, 'billing:manage') + const planReadiness = provider.readiness().plans[input.plan] + if (!planReadiness.checkoutAvailable) { + throw errors.serviceUnavailable(`${input.plan} checkout is not configured`, { + missingRequirements: planReadiness.missingRequirements, + }) + } + + const [existing] = await db.select().from(billingSubscriptions) + .where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1) + if (existing?.subscriptionId && !terminalSubscriptionStatuses.has(existing.status)) { + throw errors.conflict('当前工作室已有 Stripe 订阅,请通过客户门户管理') + } + const account = existing ?? await ensureBillingCustomer(provider, workspaceId) + const session = await provider.createCheckoutSession({ + workspaceId, + customerId: account.customerId, + plan: input.plan, + idempotencyKey: `frameflow-checkout:${workspaceId}:${input.requestId}`, + }) + await audit({ + request, + workspaceId, + action: 'billing.checkout.create', + resourceType: 'billing_subscription', + after: { plan: input.plan, checkoutSessionId: session.id }, + }) + return { url: session.url } + }) + + app.post('/workspaces/:workspaceId/billing/portal', { preHandler: requireAuth }, async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'billing:manage') + if (!provider.readiness().portalConfigured) { + throw errors.serviceUnavailable('Stripe customer portal is not configured', { + missingRequirements: ['STRIPE_SECRET_KEY'], + }) + } + const [account] = await db.select().from(billingSubscriptions) + .where(eq(billingSubscriptions.workspaceId, workspaceId)).limit(1) + if (!account) throw errors.conflict('当前工作室还没有 Stripe 账单账户') + const session = await provider.createPortalSession({ customerId: account.customerId }) + await audit({ + request, + workspaceId, + action: 'billing.portal.create', + resourceType: 'billing_subscription', + after: { portalSessionId: session.id }, + }) + return { url: session.url } + }) + + app.post('/billing/webhooks/stripe', { + bodyLimit: 1024 * 1024, + config: { rawBody: true, rateLimit: { max: 300, timeWindow: '1 minute' } }, + }, async (request) => { + if (!provider.readiness().webhookConfigured) { + throw errors.serviceUnavailable('Stripe webhooks are not configured', { + missingRequirements: ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'], + }) + } + const signature = request.headers['stripe-signature'] + if (typeof signature !== 'string' || !Buffer.isBuffer(request.rawBody)) { + throw errors.badRequest('Stripe signature and raw request body are required') + } + let event: Stripe.Event + try { + event = provider.constructWebhookEvent(request.rawBody, signature) + } catch (error) { + if (error instanceof AppError) throw error + throw errors.badRequest('Stripe webhook signature is invalid') + } + const result = await processStripeWebhookEvent(provider, event) + if (!result.duplicate && result.workspaceId) { + await audit({ + request, + workspaceId: result.workspaceId, + action: 'billing.webhook.process', + resourceType: 'billing_subscription', + after: { eventId: event.id, eventType: event.type, subscription: result.subscription }, + }) + } + return { received: true, duplicate: result.duplicate } + }) +} diff --git a/server/src/routes/episodes.ts b/server/src/routes/episodes.ts new file mode 100644 index 0000000..9e82793 --- /dev/null +++ b/server/src/routes/episodes.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq, sql } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { episodes, projects, scriptVersions, shots } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission } from '../lib/permissions.js' + +const projectParams = z.object({ projectId: z.uuid() }) +const episodeParams = z.object({ episodeId: z.uuid() }) +const scriptVersionParams = z.object({ episodeId: z.uuid(), versionId: z.uuid() }) +const createEpisodeSchema = z.object({ + episodeNumber: z.number().int().positive(), + title: z.string().trim().min(1).max(200), + synopsis: z.string().max(4000).default(''), + targetDurationSeconds: z.number().int().min(15).max(3600).default(180), +}) +const updateEpisodeSchema = createEpisodeSchema.omit({ episodeNumber: true }).partial().extend({ + status: z.enum(['OUTLINE', 'SCRIPTING', 'STORYBOARDING', 'PRODUCTION', 'REVIEW', 'PUBLISHED']).optional(), +}) +const scriptSchema = z.object({ + title: z.string().trim().min(1).max(200), + content: z.record(z.string(), z.unknown()), + plainText: z.string().max(500000), + changeSummary: z.string().max(1000).default('手动保存'), +}) + +async function episodeProject(episodeId: string) { + const [row] = await db.select({ episode: episodes, project: projects }).from(episodes).innerJoin(projects, eq(projects.id, episodes.projectId)).where(eq(episodes.id, episodeId)).limit(1) + if (!row) throw errors.notFound('Episode') + return row +} + +export async function episodeRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/projects/:projectId/episodes', async (request) => { + const { projectId } = parseWith(projectParams, request.params) + await requireProjectPermission(request, projectId, 'project:read') + return db.select().from(episodes).where(eq(episodes.projectId, projectId)).orderBy(episodes.episodeNumber) + }) + + app.post('/projects/:projectId/episodes', async (request, reply) => { + const { projectId } = parseWith(projectParams, request.params) + const input = parseWith(createEpisodeSchema, request.body) + const { project } = await requireProjectPermission(request, projectId, 'content:write') + const [episode] = await db.insert(episodes).values({ + projectId, + createdById: request.user.sub, + ...input, + status: 'OUTLINE', + }).returning() + if (!episode) throw new Error('Failed to create episode') + await audit({ request, workspaceId: project.workspaceId, action: 'episode.create', resourceType: 'episode', resourceId: episode.id, after: episode }) + return reply.code(201).send(episode) + }) + + app.get('/episodes/:episodeId', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + const [currentScript] = await db.select().from(scriptVersions).where(and(eq(scriptVersions.episodeId, episodeId), eq(scriptVersions.isCurrent, true))).limit(1) + const shotRows = await db.select().from(shots).where(eq(shots.episodeId, episodeId)).orderBy(shots.sortOrder) + return { ...row.episode, currentScript: currentScript ?? null, shots: shotRows } + }) + + app.patch('/episodes/:episodeId', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(updateEpisodeSchema, request.body) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'content:write') + const [updated] = await db.update(episodes).set({ ...input, updatedAt: new Date() }).where(eq(episodes.id, episodeId)).returning() + if (!updated) throw errors.notFound('Episode') + await audit({ request, workspaceId: row.project.workspaceId, action: 'episode.update', resourceType: 'episode', resourceId: episodeId, before: row.episode, after: updated }) + return updated + }) + + app.get('/episodes/:episodeId/scripts', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + return db.select().from(scriptVersions).where(eq(scriptVersions.episodeId, episodeId)).orderBy(desc(scriptVersions.version)) + }) + + app.post('/episodes/:episodeId/scripts', async (request, reply) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(scriptSchema, request.body) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'content:write') + + const created = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`script:${episodeId}`}))`) + const [latest] = await tx.select({ version: scriptVersions.version }).from(scriptVersions).where(eq(scriptVersions.episodeId, episodeId)).orderBy(desc(scriptVersions.version)).limit(1) + await tx.update(scriptVersions).set({ isCurrent: false }).where(eq(scriptVersions.episodeId, episodeId)) + const [version] = await tx.insert(scriptVersions).values({ + episodeId, + version: (latest?.version ?? 0) + 1, + title: input.title, + content: input.content, + plainText: input.plainText, + changeSummary: input.changeSummary, + isCurrent: true, + createdById: request.user.sub, + }).returning() + return version + }) + if (!created) throw new Error('Failed to create script version') + await audit({ request, workspaceId: row.project.workspaceId, action: 'script.version.create', resourceType: 'script_version', resourceId: created.id, after: { episodeId, version: created.version } }) + return reply.code(201).send(created) + }) + + app.post('/episodes/:episodeId/scripts/:versionId/activate', async (request) => { + const { episodeId, versionId } = parseWith(scriptVersionParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'content:write') + + const result = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`script:${episodeId}`}))`) + const [version] = await tx.select().from(scriptVersions).where(and( + eq(scriptVersions.id, versionId), + eq(scriptVersions.episodeId, episodeId), + )).limit(1) + if (!version) throw errors.notFound('Script version') + if (version.isCurrent) return { version, changed: false } + await tx.update(scriptVersions).set({ isCurrent: false }).where(eq(scriptVersions.episodeId, episodeId)) + const [activated] = await tx.update(scriptVersions).set({ isCurrent: true }).where(and( + eq(scriptVersions.id, versionId), + eq(scriptVersions.episodeId, episodeId), + )).returning() + if (!activated) throw errors.notFound('Script version') + return { version: activated, changed: true } + }) + + if (result.changed) { + await audit({ request, workspaceId: row.project.workspaceId, action: 'script.version.activate', resourceType: 'script_version', resourceId: versionId, after: { episodeId, version: result.version.version } }) + } + return result.version + }) +} diff --git a/server/src/routes/jobs.integration.test.ts b/server/src/routes/jobs.integration.test.ts new file mode 100644 index 0000000..ae128c0 --- /dev/null +++ b/server/src/routes/jobs.integration.test.ts @@ -0,0 +1,138 @@ +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { generationJobs, users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('generation job lifecycle', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: { authorization: `Bearer ${token}` }, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + async function persistJob(status: 'QUEUED' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED') { + const [job] = await db.insert(generationJobs).values({ + workspaceId, + projectId, + createdById: owner.user.id, + type: 'PIPELINE_HEALTHCHECK', + status, + progress: status === 'SUCCEEDED' ? 100 : 0, + idempotencyKey: `job-lifecycle-${crypto.randomUUID()}`, + input: { probe: 'job-lifecycle-test' }, + attempts: status === 'FAILED' ? 3 : 0, + maxAttempts: 3, + estimatedCredits: '0', + completedAt: ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(status) ? new Date() : null, + }).returning() + if (!job) throw new Error('Failed to create test job') + return job + } + + beforeAll(async () => { + app = await buildApp() + owner = await registerAccount('job-owner') + viewer = await registerAccount('job-viewer') + workspaceId = owner.workspaces[0].id + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '任务生命周期测试项目', + logline: '验证取消、重试和任务权限。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + }) + + afterAll(async () => { + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('cancels a queued task while preserving its record', async () => { + const job = await persistJob('QUEUED') + const cancelled = await request(owner.accessToken, 'DELETE', `/api/v1/jobs/${job.id}`) + expect(cancelled.status).toBe('CANCELLED') + expect(cancelled.completedAt).toBeTruthy() + + const [persisted] = await db.select().from(generationJobs).where(eq(generationJobs.id, job.id)).limit(1) + expect(persisted?.status).toBe('CANCELLED') + }) + + it('retries a failed task as a new record and keeps the source immutable', async () => { + const failed = await persistJob('FAILED') + const response = await inject(owner.accessToken, 'POST', `/api/v1/jobs/${failed.id}/retry`) + expect(response.statusCode, response.body).toBe(202) + const result = response.json() + expect(result.sourceJobId).toBe(failed.id) + expect(result.job.id).not.toBe(failed.id) + expect(result.job.input.retryOfJobId).toBe(failed.id) + expect(result.job.input.probe).toBe('job-lifecycle-test') + + const [source] = await db.select().from(generationJobs).where(eq(generationJobs.id, failed.id)).limit(1) + expect(source?.status).toBe('FAILED') + }) + + it('rejects invalid state transitions and read-only members', async () => { + const succeeded = await persistJob('SUCCEEDED') + const cancelSucceeded = await inject(owner.accessToken, 'DELETE', `/api/v1/jobs/${succeeded.id}`) + expect(cancelSucceeded.statusCode, cancelSucceeded.body).toBe(409) + const retrySucceeded = await inject(owner.accessToken, 'POST', `/api/v1/jobs/${succeeded.id}/retry`) + expect(retrySucceeded.statusCode, retrySucceeded.body).toBe(409) + + const failed = await persistJob('FAILED') + const forbiddenRetry = await inject(viewer.accessToken, 'POST', `/api/v1/jobs/${failed.id}/retry`) + expect(forbiddenRetry.statusCode, forbiddenRetry.body).toBe(403) + const queued = await persistJob('QUEUED') + const forbiddenCancel = await inject(viewer.accessToken, 'DELETE', `/api/v1/jobs/${queued.id}`) + expect(forbiddenCancel.statusCode, forbiddenCancel.body).toBe(403) + }) +}) diff --git a/server/src/routes/jobs.ts b/server/src/routes/jobs.ts new file mode 100644 index 0000000..7bddd78 --- /dev/null +++ b/server/src/routes/jobs.ts @@ -0,0 +1,142 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { deliveries, generationJobs } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission, requireWorkspacePermission, type Permission } from '../lib/permissions.js' +import { publishRealtimeEvent } from '../lib/realtime.js' +import { enqueueGeneration, generationQueue } from '../queue/client.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const projectParams = z.object({ projectId: z.uuid() }) +const jobParams = z.object({ jobId: z.uuid() }) +const listQuery = z.object({ + status: z.enum(['QUEUED', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELLED']).optional(), + type: z.enum(['PIPELINE_HEALTHCHECK', 'SCRIPT_GENERATE', 'STORYBOARD_GENERATE', 'ASSET_GENERATE', 'TTS_GENERATE', 'VIDEO_GENERATE', 'LIPSYNC_GENERATE', 'EPISODE_RENDER', 'DELIVERY_PUBLISH']).optional(), +}) +const createJobSchema = z.object({ + type: z.enum(['PIPELINE_HEALTHCHECK', 'SCRIPT_GENERATE', 'STORYBOARD_GENERATE', 'ASSET_GENERATE', 'TTS_GENERATE', 'VIDEO_GENERATE', 'LIPSYNC_GENERATE', 'EPISODE_RENDER', 'DELIVERY_PUBLISH']), + episodeId: z.uuid().optional(), + idempotencyKey: z.string().trim().min(8).max(180).optional(), + input: z.record(z.string(), z.unknown()).default({}), + maxAttempts: z.number().int().min(1).max(8).default(3), +}) + +function jobPermission(type: z.infer['type']): Permission { + if (type === 'EPISODE_RENDER') return 'render:create' + if (type === 'DELIVERY_PUBLISH') return 'publish:create' + return 'content:write' +} + +export async function jobRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/jobs', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const query = parseWith(listQuery, request.query) + await requireWorkspacePermission(request, workspaceId, 'project:read') + const filters = [eq(generationJobs.workspaceId, workspaceId)] + if (query.status) filters.push(eq(generationJobs.status, query.status)) + if (query.type) filters.push(eq(generationJobs.type, query.type)) + return db.select().from(generationJobs).where(and(...filters)).orderBy(desc(generationJobs.createdAt)).limit(200) + }) + + app.post('/projects/:projectId/jobs', async (request, reply) => { + const { projectId } = parseWith(projectParams, request.params) + const input = parseWith(createJobSchema, request.body) + const { project } = await requireProjectPermission(request, projectId, jobPermission(input.type)) + const result = await enqueueGeneration({ + workspaceId: project.workspaceId, + projectId, + userId: request.user.sub, + type: input.type, + idempotencyKey: input.idempotencyKey ?? `${input.type.toLowerCase()}-${crypto.randomUUID()}`, + payload: input.input, + maxAttempts: input.maxAttempts, + ...(input.episodeId ? { episodeId: input.episodeId } : {}), + }) + await audit({ request, workspaceId: project.workspaceId, action: 'job.enqueue', resourceType: 'generation_job', resourceId: result.job.id, after: { type: input.type, deduplicated: result.deduplicated } }) + return reply.code(result.deduplicated ? 200 : 202).send(result) + }) + + app.get('/jobs/:jobId', async (request) => { + const { jobId } = parseWith(jobParams, request.params) + const [job] = await db.select().from(generationJobs).where(eq(generationJobs.id, jobId)).limit(1) + if (!job) throw errors.notFound('Job') + await requireWorkspacePermission(request, job.workspaceId, 'project:read') + return job + }) + + app.delete('/jobs/:jobId', async (request, reply) => { + const { jobId } = parseWith(jobParams, request.params) + const [job] = await db.select().from(generationJobs).where(eq(generationJobs.id, jobId)).limit(1) + if (!job) throw errors.notFound('Job') + await requireWorkspacePermission(request, job.workspaceId, jobPermission(job.type)) + if (job.productionRunId) throw errors.conflict('This task belongs to a production run; cancel the production run instead') + if (!['QUEUED', 'RUNNING'].includes(job.status)) throw errors.conflict(`Job in ${job.status} state cannot be cancelled`) + const queueJob = await generationQueue.getJob(jobId) + if (job.type === 'DELIVERY_PUBLISH' && (job.status === 'RUNNING' || (queueJob && await queueJob.isActive()))) { + throw errors.conflict('A delivery already being published cannot be cancelled') + } + if (queueJob && !(await queueJob.isActive())) await queueJob.remove() + const updated = await db.transaction(async (tx) => { + const [cancelled] = await tx.update(generationJobs).set({ status: 'CANCELLED', completedAt: new Date(), updatedAt: new Date() }).where(eq(generationJobs.id, jobId)).returning() + if (job.type === 'DELIVERY_PUBLISH') { + const deliveryId = z.uuid().safeParse(job.input.deliveryId) + if (deliveryId.success) { + await tx.update(deliveries).set({ + status: 'CANCELLED', + jobId, + errorMessage: null, + updatedAt: new Date(), + }).where(and(eq(deliveries.id, deliveryId.data), eq(deliveries.status, 'QUEUED'))) + } + } + return cancelled + }) + await audit({ request, workspaceId: job.workspaceId, action: 'job.cancel', resourceType: 'generation_job', resourceId: jobId, before: { status: job.status }, after: { status: 'CANCELLED' } }) + await publishRealtimeEvent({ + workspaceId: job.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: jobId, + payload: { id: jobId, type: job.type, status: 'CANCELLED', progress: job.progress }, + }) + return reply.code(200).send(updated) + }) + + app.post('/jobs/:jobId/retry', async (request, reply) => { + const { jobId } = parseWith(jobParams, request.params) + const [job] = await db.select().from(generationJobs).where(eq(generationJobs.id, jobId)).limit(1) + if (!job) throw errors.notFound('Job') + await requireWorkspacePermission(request, job.workspaceId, jobPermission(job.type)) + if (job.productionRunId) throw errors.conflict('This task belongs to a production run; start a new production run instead') + if (!['FAILED', 'CANCELLED'].includes(job.status)) throw errors.conflict(`Job in ${job.status} state cannot be retried`) + + const result = await enqueueGeneration({ + workspaceId: job.workspaceId, + userId: request.user.sub, + type: job.type, + idempotencyKey: `retry-${job.id}-${crypto.randomUUID()}`, + payload: { ...job.input, retryOfJobId: job.id }, + maxAttempts: job.maxAttempts, + ...(job.projectId ? { projectId: job.projectId } : {}), + ...(job.episodeId ? { episodeId: job.episodeId } : {}), + }) + + await audit({ + request, + workspaceId: job.workspaceId, + action: 'job.retry', + resourceType: 'generation_job', + resourceId: result.job.id, + before: { sourceJobId: job.id, sourceStatus: job.status }, + after: { type: job.type, status: result.job.status }, + }) + return reply.code(202).send({ ...result, sourceJobId: job.id }) + }) +} diff --git a/server/src/routes/notifications.integration.test.ts b/server/src/routes/notifications.integration.test.ts new file mode 100644 index 0000000..2e96187 --- /dev/null +++ b/server/src/routes/notifications.integration.test.ts @@ -0,0 +1,153 @@ +import type { FastifyInstance } from 'fastify' +import { inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { users, workspaces } from '../db/schema.js' +import { createNotification } from '../lib/notifications.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('persistent notifications and realtime events', () => { + let app: FastifyInstance + let owner: Session + let outsider: Session + let workspaceId = '' + let origin = '' + const userIds: string[] = [] + const workspaceIds: string[] = [] + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + workspaceIds.push(session.workspaces[0].id) + return session + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await app.inject({ + method, + url, + headers: { authorization: `Bearer ${token}` }, + payload, + }) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + beforeAll(async () => { + app = await buildApp() + owner = await registerAccount('notification-owner') + outsider = await registerAccount('notification-outsider') + workspaceId = owner.workspaces[0].id + origin = await app.listen({ host: '127.0.0.1', port: 0 }) + }) + + afterAll(async () => { + if (workspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, workspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('persists user-scoped notifications, deduplicates retries, and tracks read state', async () => { + const input = { + workspaceId, + userId: owner.user.id, + kind: 'JOB_FAILED', + tone: 'ERROR' as const, + title: '视频生成失败', + body: '上游模型暂时不可用', + page: 'jobs', + resourceType: 'generation_job', + resourceId: crypto.randomUUID(), + dedupeKey: `notification-test-${crypto.randomUUID()}`, + } + const created = await createNotification(input) + const duplicate = await createNotification(input) + expect(created?.id).toBeTruthy() + expect(duplicate).toBeNull() + + const listed = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/notifications`) + expect(listed.unreadCount).toBe(1) + expect(listed.items.find((item: { id: string }) => item.id === created?.id)).toMatchObject({ + title: '视频生成失败', + readAt: null, + }) + + const outsiderResponse = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${workspaceId}/notifications`, + headers: { authorization: `Bearer ${outsider.accessToken}` }, + }) + expect(outsiderResponse.statusCode, outsiderResponse.body).toBe(403) + + const read = await request(owner.accessToken, 'PATCH', `/api/v1/notifications/${created?.id}`, { read: true }) + expect(read.readAt).toBeTruthy() + const afterRead = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/notifications?unreadOnly=true`) + expect(afterRead.unreadCount).toBe(0) + expect(afterRead.items).toHaveLength(0) + }) + + it('streams authenticated workspace events and supports read-all', async () => { + const controller = new AbortController() + const response = await fetch(`${origin}/api/v1/workspaces/${workspaceId}/events`, { + headers: { authorization: `Bearer ${owner.accessToken}` }, + signal: controller.signal, + }) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + const reader = response.body?.getReader() + if (!reader) throw new Error('SSE response body is unavailable') + const decoder = new TextDecoder() + let buffer = '' + let readySeen = false + while (!readySeen) { + const chunk = await reader.read() + if (chunk.done) throw new Error('SSE stream closed before ready event') + buffer += decoder.decode(chunk.value, { stream: true }) + readySeen = buffer.includes('event: realtime.ready') + } + + const created = await createNotification({ + workspaceId, + userId: owner.user.id, + kind: 'REVIEW_ASSIGNED', + tone: 'WARNING', + title: '审核任务已指派给你', + page: 'reviews', + resourceType: 'review_request', + resourceId: crypto.randomUUID(), + dedupeKey: `notification-stream-${crypto.randomUUID()}`, + }) + let notificationSeen = false + while (!notificationSeen) { + const chunk = await reader.read() + if (chunk.done) throw new Error('SSE stream closed before notification event') + buffer += decoder.decode(chunk.value, { stream: true }) + notificationSeen = buffer.includes('event: notification.created') && buffer.includes(created!.id) + } + controller.abort() + await reader.cancel().catch(() => undefined) + + const result = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/notifications/read-all`) + expect(result.updatedCount).toBeGreaterThanOrEqual(1) + const listed = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/notifications`) + expect(listed.unreadCount).toBe(0) + }) +}) diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts new file mode 100644 index 0000000..0a65911 --- /dev/null +++ b/server/src/routes/notifications.ts @@ -0,0 +1,146 @@ +import type { FastifyInstance } from 'fastify' +import type { OutgoingHttpHeaders } from 'node:http' +import { and, count, desc, eq, isNull, lt } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { notifications } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireWorkspacePermission } from '../lib/permissions.js' +import { publishRealtimeEvent, RealtimeHub, type RealtimeEvent } from '../lib/realtime.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const notificationParams = z.object({ notificationId: z.uuid() }) +const listQuery = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(30), + before: z.iso.datetime({ offset: true }).optional(), + unreadOnly: z.enum(['true', 'false']).transform((value) => value === 'true').default(false), +}) +const readBody = z.object({ read: z.boolean() }) + +function sseFrame(event: RealtimeEvent) { + return `id: ${event.id}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n` +} + +export async function notificationRoutes(app: FastifyInstance) { + const hub = new RealtimeHub((error) => app.log.error({ err: error }, 'Realtime subscriber error')) + app.addHook('preClose', async () => hub.close()) + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/notifications', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const query = parseWith(listQuery, request.query) + await requireWorkspacePermission(request, workspaceId, 'project:read') + const filters = [ + eq(notifications.workspaceId, workspaceId), + eq(notifications.userId, request.user.sub), + ] + if (query.before) filters.push(lt(notifications.createdAt, new Date(query.before))) + if (query.unreadOnly) filters.push(isNull(notifications.readAt)) + const [items, [unread]] = await Promise.all([ + db.select().from(notifications).where(and(...filters)).orderBy(desc(notifications.createdAt)).limit(query.limit + 1), + db.select({ count: count() }).from(notifications).where(and( + eq(notifications.workspaceId, workspaceId), + eq(notifications.userId, request.user.sub), + isNull(notifications.readAt), + )), + ]) + const hasMore = items.length > query.limit + const visible = items.slice(0, query.limit) + return { + items: visible, + unreadCount: Number(unread?.count ?? 0), + nextCursor: hasMore ? visible.at(-1)?.createdAt.toISOString() ?? null : null, + } + }) + + app.patch('/notifications/:notificationId', async (request) => { + const { notificationId } = parseWith(notificationParams, request.params) + const input = parseWith(readBody, request.body) + const [existing] = await db.select().from(notifications).where(and( + eq(notifications.id, notificationId), + eq(notifications.userId, request.user.sub), + )).limit(1) + if (!existing) throw errors.notFound('Notification') + await requireWorkspacePermission(request, existing.workspaceId, 'project:read') + const [updated] = await db.update(notifications).set({ + readAt: input.read ? new Date() : null, + updatedAt: new Date(), + }).where(and(eq(notifications.id, notificationId), eq(notifications.userId, request.user.sub))).returning() + if (!updated) throw errors.notFound('Notification') + await publishRealtimeEvent({ + workspaceId: updated.workspaceId, + userId: updated.userId, + type: 'notification.updated', + resourceType: 'notification', + resourceId: updated.id, + payload: { notification: updated }, + }) + return updated + }) + + app.post('/workspaces/:workspaceId/notifications/read-all', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'project:read') + const updated = await db.update(notifications).set({ readAt: new Date(), updatedAt: new Date() }).where(and( + eq(notifications.workspaceId, workspaceId), + eq(notifications.userId, request.user.sub), + isNull(notifications.readAt), + )).returning({ id: notifications.id }) + await publishRealtimeEvent({ + workspaceId, + userId: request.user.sub, + type: 'notification.read-all', + payload: { notificationIds: updated.map((item) => item.id) }, + }) + return { updatedCount: updated.length, readAt: new Date().toISOString() } + }) + + app.get('/workspaces/:workspaceId/events', async (request, reply) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'project:read') + reply + .code(200) + .header('content-type', 'text/event-stream; charset=utf-8') + .header('cache-control', 'no-cache, no-transform') + .header('connection', 'keep-alive') + .header('x-accel-buffering', 'no') + reply.hijack() + reply.raw.writeHead(200, reply.getHeaders() as OutgoingHttpHeaders) + + let closed = false + const send = (event: RealtimeEvent) => { + if (!closed && !reply.raw.destroyed) reply.raw.write(sseFrame(event)) + } + const removeClient = await hub.addClient({ + workspaceId, + userId: request.user.sub, + send, + close: () => { + if (!reply.raw.destroyed) reply.raw.end() + }, + }) + send({ + id: crypto.randomUUID(), + workspaceId, + userId: request.user.sub, + type: 'realtime.ready', + occurredAt: new Date().toISOString(), + payload: {}, + }) + const heartbeat = setInterval(() => { + if (!closed && !reply.raw.destroyed) reply.raw.write(`: heartbeat ${Date.now()}\n\n`) + }, 20_000) + heartbeat.unref() + + const cleanup = () => { + if (closed) return + closed = true + clearInterval(heartbeat) + removeClient() + } + request.raw.once('close', cleanup) + reply.raw.once('close', cleanup) + }) +} diff --git a/server/src/routes/production-runs.integration.test.ts b/server/src/routes/production-runs.integration.test.ts new file mode 100644 index 0000000..caf1ea2 --- /dev/null +++ b/server/src/routes/production-runs.integration.test.ts @@ -0,0 +1,155 @@ +import type { FastifyInstance } from 'fastify' +import { inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { generationJobs, shotVersions, users, workspaces } from '../db/schema.js' +import { generationQueue } from '../queue/client.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('whole-episode production runs', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + let episodeId = '' + let runId = '' + let rootJobId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ method, url, headers: { authorization: `Bearer ${token}` }, payload }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp() + owner = await registerAccount('production-owner') + viewer = await registerAccount('production-viewer') + workspaceId = owner.workspaces[0].id + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '整集生产测试项目', + logline: '验证批次编排、权限、列表和取消。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + const episode = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '整集生产测试集', + synopsis: '', + targetDurationSeconds: 30, + }) + episodeId = episode.id + const shot = await request(owner.accessToken, 'POST', `/api/v1/episodes/${episodeId}/shots`, { + shotNumber: 1, + sceneNumber: 1, + title: '已有成片镜头', + description: '该镜头已有可用视频,因此批次只需要创建根渲染任务。', + shotType: '中景', + cameraMotion: '静止', + durationMs: 3000, + characterAssetIds: [], + sortOrder: 100, + }) + await db.insert(shotVersions).values({ + shotId: shot.id, + version: 1, + videoStorageKey: `${workspaceId}/${projectId}/generated/shots/lipsync/existing.mp4`, + subtitleText: '', + generationParams: { operation: 'lipsync' }, + isCurrent: true, + createdById: owner.user.id, + }) + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + }) + + afterAll(async () => { + if (rootJobId) { + const queued = await generationQueue.getJob(rootJobId) + if (queued && !(await queued.isActive())) await queued.remove({ removeChildren: true }).catch(() => {}) + } + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('creates one persisted run and reserves the entire graph before enqueueing', async () => { + const response = await inject(owner.accessToken, 'POST', `/api/v1/episodes/${episodeId}/production-runs`, { + mode: 'MISSING', + imageProvider: 'openai', + enableLipsync: true, + }) + expect(response.statusCode, response.body).toBe(202) + const created = response.json() + runId = created.id + expect(created.totalJobs).toBe(1) + expect(created.render.version).toBe(1) + expect(created.jobs).toHaveLength(1) + expect(created.jobs[0].type).toBe('EPISODE_RENDER') + expect(created.jobs[0].productionRunId).toBe(runId) + rootJobId = created.jobs[0].id + + const persisted = await db.select().from(generationJobs).where(inArray(generationJobs.id, [rootJobId])) + expect(persisted[0]?.estimatedCredits).toBe('2.4000') + }) + + it('lists run progress for readers and prevents graph-breaking job actions', async () => { + const rows = await request(viewer.accessToken, 'GET', `/api/v1/episodes/${episodeId}/production-runs`) + expect(rows[0].id).toBe(runId) + expect(rows[0].summary.completedJobs).toBeGreaterThanOrEqual(0) + + const cancelJob = await inject(owner.accessToken, 'DELETE', `/api/v1/jobs/${rootJobId}`) + expect(cancelJob.statusCode, cancelJob.body).toBe(409) + expect(cancelJob.json().error.message).toContain('production run') + + const duplicate = await inject(owner.accessToken, 'POST', `/api/v1/episodes/${episodeId}/production-runs`, {}) + expect(duplicate.statusCode, duplicate.body).toBe(409) + }) + + it('enforces edit permission and cancels a run as a unit', async () => { + const forbidden = await inject(viewer.accessToken, 'DELETE', `/api/v1/production-runs/${runId}`) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + const cancelled = await request(owner.accessToken, 'DELETE', `/api/v1/production-runs/${runId}`) + expect(cancelled.status).toBe('CANCELLED') + + const detail = await request(viewer.accessToken, 'GET', `/api/v1/production-runs/${runId}`) + expect(detail.status).toBe('CANCELLED') + expect(detail.jobs.every((job: { status: string }) => ['CANCELLED', 'FAILED', 'SUCCEEDED'].includes(job.status))).toBe(true) + }) +}) diff --git a/server/src/routes/production.integration.test.ts b/server/src/routes/production.integration.test.ts new file mode 100644 index 0000000..8b19a5f --- /dev/null +++ b/server/src/routes/production.integration.test.ts @@ -0,0 +1,356 @@ +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { deliveries, generationJobs, renders, users, workspaces } from '../db/schema.js' +import { generationQueue } from '../queue/client.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('render delivery workflow', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + let episodeId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + const queueJobIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: { authorization: `Bearer ${token}` }, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + async function persistRender(version: number, withSubtitles = false) { + const [render] = await db.insert(renders).values({ + episodeId, + version, + status: 'APPROVED', + storageKey: `${workspaceId}/${projectId}/generated/renders/test-v${version}.mp4`, + mimeType: 'video/mp4', + byteSize: 1_024_000 * version, + durationMs: 12_000, + width: 1920, + height: 1080, + fps: 24, + settings: { quality: 'balanced' }, + ...(withSubtitles ? { + subtitleSrtKey: `${workspaceId}/${projectId}/generated/renders-subtitles/test-v${version}.srt`, + subtitleVttKey: `${workspaceId}/${projectId}/generated/renders-subtitles/test-v${version}.vtt`, + sourceManifest: { + schemaVersion: 1, + capturedAt: new Date().toISOString(), + shots: [ + { + shotId: crypto.randomUUID(), + shotVersionId: crypto.randomUUID(), + shotNumber: 1, + shotVersion: 2, + sortOrder: 100, + durationMs: 5_000, + subtitleText: '第一句', + imageAssetVersionId: null, + }, + { + shotId: crypto.randomUUID(), + shotVersionId: crypto.randomUUID(), + shotNumber: 2, + shotVersion: 1, + sortOrder: 200, + durationMs: 7_000, + subtitleText: '第二句', + imageAssetVersionId: null, + }, + ], + }, + } : {}), + createdById: owner.user.id, + }).returning() + if (!render) throw new Error('Failed to create test render') + return render + } + + beforeAll(async () => { + app = await buildApp() + owner = await registerAccount('delivery-owner') + viewer = await registerAccount('delivery-viewer') + workspaceId = owner.workspaces[0].id + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '交付闭环测试项目', + logline: '验证成片交付、下载和失败重试。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + const episode = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '交付测试集', + synopsis: '', + targetDurationSeconds: 60, + }) + episodeId = episode.id + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + }) + + afterAll(async () => { + for (const jobId of queueJobIds) { + for (let attempt = 0; attempt < 100; attempt += 1) { + const job = await generationQueue.getJob(jobId) + if (!job) break + if (await job.isActive()) { + await new Promise((resolve) => setTimeout(resolve, 25)) + continue + } + await job.remove() + break + } + } + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('lists episode deliveries with render version context', async () => { + const render = await persistRender(1) + const [delivery] = await db.insert(deliveries).values({ + renderId: render.id, + platform: 'DOWNLOAD', + status: 'QUEUED', + createdById: owner.user.id, + }).returning() + if (!delivery) throw new Error('Failed to create test delivery') + + const rows = await request(owner.accessToken, 'GET', `/api/v1/episodes/${episodeId}/deliveries`) + const listed = rows.find((item: { id: string }) => item.id === delivery.id) + expect(listed.status).toBe('QUEUED') + expect(listed.render.id).toBe(render.id) + expect(listed.render.version).toBe(1) + expect(listed.render.width).toBe(1920) + + const viewerRows = await request(viewer.accessToken, 'GET', `/api/v1/episodes/${episodeId}/deliveries`) + expect(viewerRows.some((item: { id: string }) => item.id === delivery.id)).toBe(true) + }) + + it('returns a short-lived signed download URL only for published delivery', async () => { + const render = await persistRender(2) + const [published, failed] = await db.insert(deliveries).values([ + { + renderId: render.id, + platform: 'DOWNLOAD', + status: 'PUBLISHED', + publishedUrl: '/api/v1/deliveries/test/download', + publishedAt: new Date(), + createdById: owner.user.id, + }, + { + renderId: render.id, + platform: 'ARCHIVE_TEST', + status: 'FAILED', + errorMessage: 'Test failure', + createdById: owner.user.id, + }, + ]).returning() + if (!published || !failed) throw new Error('Failed to create delivery states') + + const detail = await request(viewer.accessToken, 'GET', `/api/v1/deliveries/${published.id}`) + expect(detail.status).toBe('PUBLISHED') + expect(detail.render.version).toBe(2) + expect(detail.downloadExpiresIn).toBe(300) + expect(detail.downloadUrl).toContain('X-Amz-Signature=') + + const failedDetail = await request(owner.accessToken, 'GET', `/api/v1/deliveries/${failed.id}`) + expect(failedDetail.downloadUrl).toBeNull() + expect(failedDetail.downloadExpiresIn).toBeNull() + }) + + it('returns signed immutable SRT and VTT sidecars without exposing storage internals', async () => { + const render = await persistRender(7, true) + const detail = await request(viewer.accessToken, 'GET', `/api/v1/renders/${render.id}`) + expect(detail.subtitleFormats).toEqual(['srt', 'vtt']) + expect(detail.sourceShotCount).toBe(2) + expect(detail).not.toHaveProperty('subtitleSrtKey') + expect(detail).not.toHaveProperty('subtitleVttKey') + expect(detail).not.toHaveProperty('sourceManifest') + + for (const format of ['srt', 'vtt']) { + const subtitle = await request(viewer.accessToken, 'GET', `/api/v1/renders/${render.id}/subtitles/${format}`) + expect(subtitle.format).toBe(format) + expect(subtitle.filename).toBe(`EP01-交付测试集-v7.${format}`) + expect(subtitle.downloadExpiresIn).toBe(300) + const signedUrl = new URL(subtitle.downloadUrl) + expect(signedUrl.searchParams.get('X-Amz-Signature')).toBeTruthy() + expect(signedUrl.searchParams.get('response-content-disposition')).toContain('attachment;') + expect(signedUrl.searchParams.get('response-content-disposition')).toContain(`.${format}`) + } + + const invalid = await inject(owner.accessToken, 'GET', `/api/v1/renders/${render.id}/subtitles/ass`) + expect(invalid.statusCode, invalid.body).toBe(400) + + const renderWithoutSubtitles = await persistRender(8) + const missing = await inject(owner.accessToken, 'GET', `/api/v1/renders/${renderWithoutSubtitles.id}/subtitles/srt`) + expect(missing.statusCode, missing.body).toBe(404) + }) + + it('prevents duplicate active or published downloads for one render', async () => { + const render = await persistRender(3) + await db.insert(deliveries).values({ + renderId: render.id, + platform: 'DOWNLOAD', + status: 'PUBLISHED', + publishedAt: new Date(), + createdById: owner.user.id, + }) + + const duplicate = await inject(owner.accessToken, 'POST', `/api/v1/renders/${render.id}/deliveries`, { platform: 'DOWNLOAD' }) + expect(duplicate.statusCode, duplicate.body).toBe(409) + expect(duplicate.json().error.message).toContain('already exists') + + const forbidden = await inject(viewer.accessToken, 'POST', `/api/v1/renders/${render.id}/deliveries`, { platform: 'DOWNLOAD' }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + }) + + it('retries a failed delivery and rejects invalid or read-only retries', async () => { + const render = await persistRender(4) + const [failed] = await db.insert(deliveries).values({ + renderId: render.id, + platform: 'DOWNLOAD', + status: 'FAILED', + errorMessage: 'Temporary queue failure', + createdById: owner.user.id, + }).returning() + if (!failed) throw new Error('Failed to create retry delivery') + + const forbidden = await inject(viewer.accessToken, 'POST', `/api/v1/deliveries/${failed.id}/retry`) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + const retried = await inject(owner.accessToken, 'POST', `/api/v1/deliveries/${failed.id}/retry`) + expect(retried.statusCode, retried.body).toBe(202) + const result = retried.json() + queueJobIds.push(result.job.id) + expect(result.delivery.id).toBe(failed.id) + expect(result.delivery.status).toBe('QUEUED') + expect(result.delivery.jobId).toBe(result.job.id) + expect(result.delivery.errorMessage).toBeNull() + expect(result.job.type).toBe('DELIVERY_PUBLISH') + expect(result.job.input.deliveryId).toBe(failed.id) + + const repeated = await inject(owner.accessToken, 'POST', `/api/v1/deliveries/${failed.id}/retry`) + expect(repeated.statusCode, repeated.body).toBe(409) + }) + + it('schedules, cancels and retries a queued delivery', async () => { + const render = await persistRender(5) + const scheduledAt = new Date(Date.now() + 30 * 60_000).toISOString() + const createdResponse = await inject(owner.accessToken, 'POST', `/api/v1/renders/${render.id}/deliveries`, { + platform: 'DOWNLOAD', + scheduledAt, + }) + expect(createdResponse.statusCode, createdResponse.body).toBe(202) + const created = createdResponse.json() + queueJobIds.push(created.job.id) + expect(created.delivery.status).toBe('QUEUED') + expect(created.delivery.jobId).toBe(created.job.id) + expect(new Date(created.delivery.scheduledAt).toISOString()).toBe(scheduledAt) + + const delayedJob = await generationQueue.getJob(created.job.id) + expect(delayedJob).toBeTruthy() + expect(await delayedJob?.getState()).toBe('delayed') + expect(delayedJob?.delay).toBeGreaterThan(25 * 60_000) + + const forbidden = await inject(viewer.accessToken, 'DELETE', `/api/v1/deliveries/${created.delivery.id}`) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + const cancelled = await request(owner.accessToken, 'DELETE', `/api/v1/deliveries/${created.delivery.id}`) + expect(cancelled.status).toBe('CANCELLED') + expect(cancelled.jobId).toBe(created.job.id) + expect(cancelled.errorMessage).toBeNull() + expect(await generationQueue.getJob(created.job.id)).toBeUndefined() + + const [cancelledJob] = await db.select().from(generationJobs).where(eq(generationJobs.id, created.job.id)).limit(1) + expect(cancelledJob?.status).toBe('CANCELLED') + const repeatedCancel = await inject(owner.accessToken, 'DELETE', `/api/v1/deliveries/${created.delivery.id}`) + expect(repeatedCancel.statusCode, repeatedCancel.body).toBe(409) + + const retriedResponse = await inject(owner.accessToken, 'POST', `/api/v1/deliveries/${created.delivery.id}/retry`) + expect(retriedResponse.statusCode, retriedResponse.body).toBe(202) + const retried = retriedResponse.json() + queueJobIds.push(retried.job.id) + expect(retried.delivery.status).toBe('QUEUED') + expect(retried.delivery.jobId).toBe(retried.job.id) + expect(retried.delivery.jobId).not.toBe(created.job.id) + expect(retried.delivery.scheduledAt).toBeNull() + }) + + it('keeps delivery state in sync when cancellation starts from the task center', async () => { + const render = await persistRender(6) + const [delivery] = await db.insert(deliveries).values({ + renderId: render.id, + platform: 'DOWNLOAD', + status: 'QUEUED', + createdById: owner.user.id, + }).returning() + if (!delivery) throw new Error('Failed to create cancelled delivery') + + const [job] = await db.insert(generationJobs).values({ + workspaceId, + projectId, + episodeId, + createdById: owner.user.id, + type: 'DELIVERY_PUBLISH', + status: 'QUEUED', + idempotencyKey: `delivery-cancel-${crypto.randomUUID()}`, + input: { deliveryId: delivery.id }, + estimatedCredits: '0', + }).returning() + if (!job) throw new Error('Failed to create cancelled delivery job') + await db.update(deliveries).set({ jobId: job.id }).where(eq(deliveries.id, delivery.id)) + + const cancelled = await request(owner.accessToken, 'DELETE', `/api/v1/jobs/${job.id}`) + expect(cancelled.status).toBe('CANCELLED') + + const detail = await request(owner.accessToken, 'GET', `/api/v1/deliveries/${delivery.id}`) + expect(detail.status).toBe('CANCELLED') + expect(detail.jobId).toBe(job.id) + expect(detail.errorMessage).toBeNull() + expect(detail.downloadUrl).toBeNull() + }) +}) diff --git a/server/src/routes/production.ts b/server/src/routes/production.ts new file mode 100644 index 0000000..5aaffc6 --- /dev/null +++ b/server/src/routes/production.ts @@ -0,0 +1,805 @@ +import type { FastifyInstance } from 'fastify' +import type { FlowJob } from 'bullmq' +import { and, asc, desc, eq, gte, inArray, isNull, lt, ne, sql } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { + assetVersions, + deliveries, + episodes, + generationJobs, + publishingChannelAccounts, + productionRuns, + projects, + renders, + shots, + shotVersions, + usageLedger, + workspaces, +} from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { billingPeriod, estimateJobCredits } from '../lib/billing.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission } from '../lib/permissions.js' +import { episodeRenderSettingsSchema } from '../media/ffmpeg.js' +import { assertJobProviderConfigured } from '../providers/readiness.js' +import { enqueueGeneration, generationFlowProducer, generationQueue } from '../queue/client.js' +import { + buildShotProductionChain, + flattenProductionPlan, + type ProductionPlanNode, + type ProductionPlanSettings, +} from '../queue/production-plan.js' +import { GENERATION_QUEUE, type GenerationJobData, type JobKind } from '../queue/types.js' +import { presignDownload } from '../storage/client.js' +import { createPublishingAdapterRegistry } from '../publishing/connector.js' +import { externalPublishingPlatforms, type PublishingAdapterRegistry } from '../publishing/types.js' + +const episodeParams = z.object({ episodeId: z.uuid() }) +const renderParams = z.object({ renderId: z.uuid() }) +const renderSubtitleParams = z.object({ renderId: z.uuid(), format: z.enum(['srt', 'vtt']) }) +const deliveryParams = z.object({ deliveryId: z.uuid() }) +const productionRunParams = z.object({ runId: z.uuid() }) +const defaultRenderSettings = { + width: 1920, + height: 1080, + fps: 24 as const, + quality: 'balanced' as const, + burnSubtitles: true, + normalizeAudio: true, + musicVolume: 0.18, + subtitleStyle: { + fontSize: 24, + textColor: '#FFFFFF', + outlineColor: '#000000', + outlineWidth: 2, + bottomMargin: 48, + bold: true, + }, +} +const createRenderSchema = z.object({ + settings: episodeRenderSettingsSchema.default(defaultRenderSettings), + idempotencyKey: z.string().trim().min(8).max(180).optional(), +}) +const createProductionRunSchema = z.object({ + mode: z.enum(['MISSING', 'REGENERATE']).default('MISSING'), + imageProvider: z.enum(['openai', 'replicate', 'agnes', 'generic']).default('openai'), + videoProvider: z.enum(['replicate', 'agnes', 'generic']).default('replicate'), + imageWidth: z.number().int().min(256).max(4096).default(1536), + imageHeight: z.number().int().min(256).max(4096).default(1024), + voice: z.string().trim().min(1).max(80).default('alloy'), + speechSpeed: z.number().min(0.25).max(4).default(1), + enableLipsync: z.boolean().default(true), + videoParams: z.record(z.string(), z.unknown()).default({}), + lipsyncParams: z.record(z.string(), z.unknown()).default({}), + renderSettings: episodeRenderSettingsSchema.default(defaultRenderSettings), +}) +const createDeliverySchema = z.object({ + platform: z.enum(['DOWNLOAD', ...externalPublishingPlatforms]).default('DOWNLOAD'), + channelAccountId: z.uuid().optional(), + destination: z.record(z.string(), z.unknown()).default({}), + scheduledAt: z.iso.datetime({ offset: true }).optional(), +}).superRefine((input, context) => { + if (input.platform === 'DOWNLOAD' && input.channelAccountId) { + context.addIssue({ code: 'custom', path: ['channelAccountId'], message: 'Download delivery does not use a channel account' }) + } + if (input.platform !== 'DOWNLOAD' && !input.channelAccountId) { + context.addIssue({ code: 'custom', path: ['channelAccountId'], message: 'External publishing requires a channel account' }) + } +}) + +interface ProductionRouteOptions { + publishingAdapters?: PublishingAdapterRegistry +} + +async function episodeProject(episodeId: string) { + const [row] = await db.select({ episode: episodes, project: projects }).from(episodes) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(episodes.id, episodeId)).limit(1) + if (!row) throw errors.notFound('Episode') + return row +} + +async function renderProject(renderId: string) { + const [row] = await db.select({ render: renders, episode: episodes, project: projects }).from(renders) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(renders.id, renderId)).limit(1) + if (!row) throw errors.notFound('Render') + return row +} + +async function deliveryProject(deliveryId: string) { + const [row] = await db.select({ delivery: deliveries, render: renders, episode: episodes, project: projects, channelAccount: publishingChannelAccounts }).from(deliveries) + .innerJoin(renders, eq(renders.id, deliveries.renderId)) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .leftJoin(publishingChannelAccounts, eq(publishingChannelAccounts.id, deliveries.channelAccountId)) + .where(eq(deliveries.id, deliveryId)).limit(1) + if (!row) throw errors.notFound('Delivery') + return row +} + +function publicChannelAccount(account: typeof publishingChannelAccounts.$inferSelect | null) { + if (!account) return null + return { + id: account.id, + platform: account.platform, + externalAccountId: account.externalAccountId, + displayName: account.displayName, + avatarUrl: account.avatarUrl, + status: account.status, + } +} + +function renderResponse(render: typeof renders.$inferSelect) { + const { subtitleSrtKey, subtitleVttKey, sourceManifest, ...publicFields } = render + return { + ...publicFields, + subtitleFormats: [ + ...(subtitleSrtKey ? ['srt' as const] : []), + ...(subtitleVttKey ? ['vtt' as const] : []), + ], + sourceShotCount: sourceManifest?.shots.length ?? null, + } +} + +interface PersistedPlanNode extends ProductionPlanNode { + id: string + children?: PersistedPlanNode[] +} + +function assignPlanIds(node: ProductionPlanNode): PersistedPlanNode { + const { children, ...job } = node + return { + ...job, + id: crypto.randomUUID(), + ...(children ? { children: children.map(assignPlanIds) } : {}), + } +} + +function flattenPersistedPlan(nodes: PersistedPlanNode[]) { + const rows: PersistedPlanNode[] = [] + const visit = (node: PersistedPlanNode) => { + rows.push(node) + node.children?.forEach(visit) + } + nodes.forEach(visit) + return rows +} + +function flowNode(input: { + node: PersistedPlanNode + workspaceId: string + projectId: string + episodeId: string + userId: string + child?: boolean +}): FlowJob { + const data: GenerationJobData = { + databaseId: input.node.id, + workspaceId: input.workspaceId, + projectId: input.projectId, + episodeId: input.episodeId, + userId: input.userId, + type: input.node.type, + input: input.node.input, + } + return { + name: input.node.type, + queueName: GENERATION_QUEUE, + data, + opts: { + jobId: input.node.id, + attempts: input.node.type === 'EPISODE_RENDER' ? 2 : 3, + backoff: { type: 'exponential', delay: 3000 }, + removeOnComplete: { age: 24 * 60 * 60, count: 1000 }, + removeOnFail: { age: 7 * 24 * 60 * 60, count: 5000 }, + ...(input.child ? { ignoreDependencyOnFailure: true } : {}), + }, + ...(input.node.children?.length ? { + children: input.node.children.map((child) => flowNode({ ...input, node: child, child: true })), + } : {}), + } +} + +function summarizeRunJobs(runJobs: Array) { + const terminal = runJobs.filter((job) => ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(job.status)) + const activeJobTypes = [...new Set(runJobs.filter((job) => job.status === 'RUNNING').map((job) => job.type))] + const failedJob = runJobs.find((job) => job.status === 'FAILED') + return { + completedJobs: terminal.length, + succeededJobs: runJobs.filter((job) => job.status === 'SUCCEEDED').length, + failedJobs: runJobs.filter((job) => job.status === 'FAILED').length, + cancelledJobs: runJobs.filter((job) => job.status === 'CANCELLED').length, + progress: runJobs.length ? Math.round(runJobs.reduce((total, job) => total + job.progress, 0) / runJobs.length) : 0, + activeJobTypes, + failedStep: failedJob ? { type: failedJob.type, message: failedJob.errorMessage } : null, + } +} + +async function hydrateProductionRuns(rows: Array<{ run: typeof productionRuns.$inferSelect, render: typeof renders.$inferSelect }>) { + if (rows.length === 0) return [] + const runJobs = await db.select().from(generationJobs) + .where(inArray(generationJobs.productionRunId, rows.map(({ run }) => run.id))) + .orderBy(asc(generationJobs.createdAt)) + return rows.map(({ run, render }) => { + const jobs = runJobs.filter((job) => job.productionRunId === run.id) + return { ...run, render: renderResponse(render), summary: summarizeRunJobs(jobs), jobs } + }) +} + +export async function productionRoutes(app: FastifyInstance, options: ProductionRouteOptions = {}) { + const publishingAdapters = options.publishingAdapters ?? createPublishingAdapterRegistry() + app.addHook('preHandler', requireAuth) + + app.get('/episodes/:episodeId/production-runs', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + const runRows = await db.select({ run: productionRuns, render: renders }).from(productionRuns) + .innerJoin(renders, eq(renders.id, productionRuns.renderId)) + .where(eq(productionRuns.episodeId, episodeId)) + .orderBy(desc(productionRuns.createdAt)).limit(20) + return hydrateProductionRuns(runRows) + }) + + app.get('/production-runs/:runId', async (request) => { + const { runId } = parseWith(productionRunParams, request.params) + const [row] = await db.select({ run: productionRuns, render: renders, episode: episodes, project: projects }).from(productionRuns) + .innerJoin(renders, eq(renders.id, productionRuns.renderId)) + .innerJoin(episodes, eq(episodes.id, productionRuns.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(productionRuns.id, runId)).limit(1) + if (!row) throw errors.notFound('Production run') + await requireProjectPermission(request, row.project.id, 'project:read') + const [hydrated] = await hydrateProductionRuns([{ run: row.run, render: row.render }]) + return hydrated + }) + + app.post('/episodes/:episodeId/production-runs', async (request, reply) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(createProductionRunSchema, request.body) + const context = await episodeProject(episodeId) + await requireProjectPermission(request, context.project.id, 'render:create') + if (input.renderSettings.backgroundMusicStorageKey) { + const prefix = `${context.project.workspaceId}/${context.project.id}/` + if (!input.renderSettings.backgroundMusicStorageKey.startsWith(prefix)) throw errors.forbidden('Background music must belong to this project') + } + + const shotRows = await db.select({ shot: shots, version: shotVersions, image: assetVersions }).from(shots) + .leftJoin(shotVersions, and(eq(shotVersions.shotId, shots.id), eq(shotVersions.isCurrent, true))) + .leftJoin(assetVersions, eq(assetVersions.id, shotVersions.imageAssetVersionId)) + .where(eq(shots.episodeId, episodeId)).orderBy(asc(shots.sortOrder)) + if (shotRows.length === 0) throw errors.badRequest('The episode has no shots to produce') + + const planSettings: ProductionPlanSettings = { + mode: input.mode, + imageProvider: input.imageProvider, + videoProvider: input.videoProvider, + imageWidth: input.imageWidth, + imageHeight: input.imageHeight, + voice: input.voice, + speechSpeed: input.speechSpeed, + enableLipsync: input.enableLipsync, + videoParams: input.videoParams, + lipsyncParams: input.lipsyncParams, + } + const plannedChains = shotRows.map(({ shot, version, image }) => buildShotProductionChain({ + id: shot.id, + title: shot.title, + description: shot.description, + durationMs: shot.durationMs, + subtitleText: version?.subtitleText ?? '', + imageAssetVersionId: version?.imageAssetVersionId ?? null, + imageStorageKey: image?.storageKey ?? null, + videoStorageKey: version?.videoStorageKey ?? null, + audioStorageKey: version?.audioStorageKey ?? null, + generationParams: version?.generationParams ?? {}, + }, planSettings)).filter((node): node is ProductionPlanNode => Boolean(node)) + const plannedJobs = flattenProductionPlan(plannedChains) + plannedJobs.forEach((job) => assertJobProviderConfigured(job.type, job.input)) + + const assignedChains = plannedChains.map(assignPlanIds) + const assignedJobs = flattenPersistedPlan(assignedChains) + const runId = crypto.randomUUID() + const renderId = crypto.randomUUID() + const rootJobId = crypto.randomUUID() + const prerequisiteJobIds = assignedJobs.map((job) => job.id) + const rootNode: PersistedPlanNode = { + id: rootJobId, + type: 'EPISODE_RENDER', + input: { renderId, prerequisiteJobIds }, + children: assignedChains, + } + const estimatedCredits = await Promise.all(assignedJobs.map((job) => estimateJobCredits(job.type, job.input))) + const renderCredits = Math.max(1, shotRows.reduce((total, { shot }) => total + shot.durationMs, 0) / 1000) * 0.8 + const totalEstimatedCredits = estimatedCredits.reduce((total, credits) => total + credits, renderCredits) + const now = new Date() + + const persisted = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`production:${episodeId}`}))`) + const [active] = await tx.select({ id: productionRuns.id }).from(productionRuns).where(and( + eq(productionRuns.episodeId, episodeId), + inArray(productionRuns.status, ['QUEUED', 'RUNNING']), + )).limit(1) + if (active) throw errors.conflict('An active production run already exists for this episode') + + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`billing:${context.project.workspaceId}`}))`) + const [workspace] = await tx.select({ monthlyCreditLimit: workspaces.monthlyCreditLimit }).from(workspaces) + .where(eq(workspaces.id, context.project.workspaceId)).limit(1) + if (!workspace) throw errors.notFound('Workspace') + const { start, end } = billingPeriod(now) + const [[usage], [reservation]] = await Promise.all([ + tx.select({ credits: sql`coalesce(sum(${usageLedger.credits}), 0)` }).from(usageLedger).where(and( + eq(usageLedger.workspaceId, context.project.workspaceId), + gte(usageLedger.occurredAt, start), + lt(usageLedger.occurredAt, end), + )), + tx.select({ credits: sql`coalesce(sum(${generationJobs.estimatedCredits}), 0)` }).from(generationJobs).where(and( + eq(generationJobs.workspaceId, context.project.workspaceId), + inArray(generationJobs.status, ['QUEUED', 'RUNNING']), + )), + ]) + const usedCredits = Number(usage?.credits ?? 0) + const reservedCredits = Number(reservation?.credits ?? 0) + const remainingCredits = Math.max(0, workspace.monthlyCreditLimit - usedCredits - reservedCredits) + if (totalEstimatedCredits > remainingCredits) { + throw errors.creditLimitExceeded({ + monthlyCreditLimit: workspace.monthlyCreditLimit, + usedCredits, + reservedCredits, + remainingCredits, + estimatedCredits: totalEstimatedCredits, + }) + } + + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`render:${episodeId}`}))`) + const [latest] = await tx.select({ version: renders.version }).from(renders) + .where(eq(renders.episodeId, episodeId)).orderBy(desc(renders.version)).limit(1) + const renderVersion = (latest?.version ?? 0) + 1 + const [render] = await tx.insert(renders).values({ + id: renderId, + episodeId, + version: renderVersion, + width: input.renderSettings.width, + height: input.renderSettings.height, + fps: input.renderSettings.fps, + settings: input.renderSettings, + createdById: request.user.sub, + }).returning() + const [run] = await tx.insert(productionRuns).values({ + id: runId, + episodeId, + renderId, + settings: input, + totalJobs: assignedJobs.length + 1, + createdById: request.user.sub, + }).returning() + if (!render || !run) throw new Error('Failed to persist production run') + + const values = [ + { node: rootNode, credits: renderCredits, maxAttempts: 2 }, + ...assignedJobs.map((node, index) => ({ node, credits: estimatedCredits[index]!, maxAttempts: 3 })), + ].map(({ node, credits, maxAttempts }) => ({ + id: node.id, + workspaceId: context.project.workspaceId, + projectId: context.project.id, + episodeId, + productionRunId: runId, + createdById: request.user.sub, + type: node.type, + idempotencyKey: `production-${runId}-${node.id}`, + input: node.input, + maxAttempts, + estimatedCredits: credits.toString(), + })) + const jobs = await tx.insert(generationJobs).values(values).returning() + await tx.update(episodes).set({ status: 'PRODUCTION', updatedAt: now }).where(eq(episodes.id, episodeId)) + return { run, render, jobs } + }) + + try { + await generationFlowProducer.add(flowNode({ + node: rootNode, + workspaceId: context.project.workspaceId, + projectId: context.project.id, + episodeId, + userId: request.user.sub, + })) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to enqueue production flow' + await db.transaction(async (tx) => { + await tx.update(generationJobs).set({ status: 'FAILED', errorCode: 'QUEUE_UNAVAILABLE', errorMessage: message, completedAt: new Date(), updatedAt: new Date() }) + .where(eq(generationJobs.productionRunId, runId)) + await tx.update(productionRuns).set({ status: 'FAILED', completedAt: new Date(), updatedAt: new Date() }).where(eq(productionRuns.id, runId)) + await tx.update(renders).set({ status: 'FAILED', qualityReport: { error: message }, updatedAt: new Date() }).where(eq(renders.id, renderId)) + }) + throw error + } + + await audit({ + request, + workspaceId: context.project.workspaceId, + action: 'production_run.create', + resourceType: 'production_run', + resourceId: runId, + after: { renderId, totalJobs: persisted.jobs.length, estimatedCredits: totalEstimatedCredits, mode: input.mode }, + }) + const [hydrated] = await hydrateProductionRuns([{ run: persisted.run, render: persisted.render }]) + return reply.code(202).send(hydrated) + }) + + app.delete('/production-runs/:runId', async (request, reply) => { + const { runId } = parseWith(productionRunParams, request.params) + const [row] = await db.select({ run: productionRuns, render: renders, episode: episodes, project: projects }).from(productionRuns) + .innerJoin(renders, eq(renders.id, productionRuns.renderId)) + .innerJoin(episodes, eq(episodes.id, productionRuns.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(productionRuns.id, runId)).limit(1) + if (!row) throw errors.notFound('Production run') + await requireProjectPermission(request, row.project.id, 'render:create') + if (!['QUEUED', 'RUNNING'].includes(row.run.status)) throw errors.conflict(`Production run in ${row.run.status} state cannot be cancelled`) + + const runJobs = await db.select().from(generationJobs).where(eq(generationJobs.productionRunId, runId)) + const rootJob = runJobs.find((job) => job.type === 'EPISODE_RENDER') + const completedAt = new Date() + const [cancelled] = await db.transaction(async (tx) => { + const [updated] = await tx.update(productionRuns).set({ status: 'CANCELLED', completedAt, updatedAt: completedAt }) + .where(and(eq(productionRuns.id, runId), inArray(productionRuns.status, ['QUEUED', 'RUNNING']))).returning() + if (!updated) throw errors.conflict('Production run is no longer active') + await tx.update(generationJobs).set({ status: 'CANCELLED', completedAt, updatedAt: completedAt }) + .where(and(eq(generationJobs.productionRunId, runId), inArray(generationJobs.status, ['QUEUED', 'RUNNING']))) + await tx.update(renders).set({ status: 'FAILED', qualityReport: { error: 'Production run cancelled' }, updatedAt: completedAt }) + .where(and(eq(renders.id, row.render.id), inArray(renders.status, ['QUEUED', 'RENDERING']))) + return [updated] + }) + if (rootJob) { + try { + const queuedRoot = await generationQueue.getJob(rootJob.id) + if (queuedRoot && !(await queuedRoot.isActive())) await queuedRoot.remove({ removeChildren: true }) + } catch (error) { + request.log.warn({ err: error, runId }, 'Production flow remains in Redis until active work observes cancellation') + } + } + await audit({ request, workspaceId: row.project.workspaceId, action: 'production_run.cancel', resourceType: 'production_run', resourceId: runId, before: { status: row.run.status }, after: { status: 'CANCELLED' } }) + return reply.code(200).send(cancelled) + }) + + app.get('/episodes/:episodeId/renders', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + const rows = await db.select().from(renders).where(eq(renders.episodeId, episodeId)).orderBy(desc(renders.version)) + return rows.map(renderResponse) + }) + + app.get('/episodes/:episodeId/deliveries', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + const rows = await db.select({ delivery: deliveries, render: renders, channelAccount: publishingChannelAccounts }).from(deliveries) + .innerJoin(renders, eq(renders.id, deliveries.renderId)) + .leftJoin(publishingChannelAccounts, eq(publishingChannelAccounts.id, deliveries.channelAccountId)) + .where(eq(renders.episodeId, episodeId)) + .orderBy(desc(deliveries.createdAt)) + return rows.map(({ delivery, render, channelAccount }) => ({ ...delivery, render: renderResponse(render), channelAccount: publicChannelAccount(channelAccount) })) + }) + + app.post('/episodes/:episodeId/renders', async (request, reply) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(createRenderSchema, request.body) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'render:create') + if (input.settings.backgroundMusicStorageKey) { + const prefix = `${row.project.workspaceId}/${row.project.id}/` + if (!input.settings.backgroundMusicStorageKey.startsWith(prefix)) throw errors.forbidden('Background music must belong to this project') + } + + const render = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`render:${episodeId}`}))`) + const [latest] = await tx.select({ version: renders.version }).from(renders) + .where(eq(renders.episodeId, episodeId)).orderBy(desc(renders.version)).limit(1) + const [created] = await tx.insert(renders).values({ + episodeId, + version: (latest?.version ?? 0) + 1, + width: input.settings.width, + height: input.settings.height, + fps: input.settings.fps, + settings: input.settings, + createdById: request.user.sub, + }).returning() + return created + }) + if (!render) throw new Error('Failed to create render') + + try { + const queued = await enqueueGeneration({ + workspaceId: row.project.workspaceId, + projectId: row.project.id, + episodeId, + userId: request.user.sub, + type: 'EPISODE_RENDER', + idempotencyKey: input.idempotencyKey ?? `render-${render.id}`, + payload: { renderId: render.id }, + maxAttempts: 2, + }) + await audit({ request, workspaceId: row.project.workspaceId, action: 'render.create', resourceType: 'render', resourceId: render.id, after: { version: render.version, jobId: queued.job.id } }) + return reply.code(202).send({ render: renderResponse(render), job: queued.job }) + } catch (error) { + await db.update(renders).set({ status: 'FAILED', qualityReport: { error: 'Failed to enqueue render' }, updatedAt: new Date() }).where(eq(renders.id, render.id)) + throw error + } + }) + + app.get('/renders/:renderId', async (request) => { + const { renderId } = parseWith(renderParams, request.params) + const row = await renderProject(renderId) + await requireProjectPermission(request, row.project.id, 'project:read') + return { + ...renderResponse(row.render), + downloadUrl: row.render.storageKey ? await presignDownload(row.render.storageKey) : null, + downloadExpiresIn: row.render.storageKey ? 900 : null, + } + }) + + app.get('/renders/:renderId/subtitles/:format', async (request) => { + const { renderId, format } = parseWith(renderSubtitleParams, request.params) + const row = await renderProject(renderId) + await requireProjectPermission(request, row.project.id, 'project:read') + const storageKey = format === 'srt' ? row.render.subtitleSrtKey : row.render.subtitleVttKey + if (!storageKey) throw errors.notFound(`Render ${format.toUpperCase()} subtitle`) + const projectPrefix = `${row.project.workspaceId}/${row.project.id}/` + if (!storageKey.startsWith(projectPrefix)) throw errors.forbidden('Render subtitle must belong to this project') + const filename = `EP${String(row.episode.episodeNumber).padStart(2, '0')}-${row.episode.title}-v${row.render.version}.${format}` + return { + format, + filename, + downloadUrl: await presignDownload(storageKey, 300, filename), + downloadExpiresIn: 300, + } + }) + + app.get('/renders/:renderId/deliveries', async (request) => { + const { renderId } = parseWith(renderParams, request.params) + const row = await renderProject(renderId) + await requireProjectPermission(request, row.project.id, 'project:read') + const rows = await db.select({ delivery: deliveries, channelAccount: publishingChannelAccounts }).from(deliveries) + .leftJoin(publishingChannelAccounts, eq(publishingChannelAccounts.id, deliveries.channelAccountId)) + .where(eq(deliveries.renderId, renderId)).orderBy(desc(deliveries.createdAt)) + return rows.map(({ delivery, channelAccount }) => ({ ...delivery, channelAccount: publicChannelAccount(channelAccount) })) + }) + + app.post('/renders/:renderId/deliveries', async (request, reply) => { + const { renderId } = parseWith(renderParams, request.params) + const input = parseWith(createDeliverySchema, request.body) + const row = await renderProject(renderId) + await requireProjectPermission(request, row.project.id, 'publish:create') + if (row.render.status !== 'APPROVED' || !row.render.storageKey) throw errors.conflict('Render must be approved before delivery') + const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null + if (scheduledAt && scheduledAt.getTime() < Date.now() - 60_000) throw errors.badRequest('scheduledAt cannot be in the past') + let channelAccount: typeof publishingChannelAccounts.$inferSelect | null = null + if (input.platform !== 'DOWNLOAD') { + const [account] = await db.select().from(publishingChannelAccounts).where(and( + eq(publishingChannelAccounts.id, input.channelAccountId!), + eq(publishingChannelAccounts.workspaceId, row.project.workspaceId), + eq(publishingChannelAccounts.platform, input.platform), + )).limit(1) + if (!account) throw errors.notFound('Publishing channel account') + if (account.status !== 'CONNECTED' || !account.accessTokenCiphertext) throw errors.conflict('Publishing channel account is not connected') + const readiness = publishingAdapters.get(input.platform)?.readiness() + if (!readiness || readiness.status !== 'CONFIGURED') { + throw errors.serviceUnavailable(`${input.platform} publishing adapter is not configured`, { + platform: input.platform, + missingRequirements: readiness?.missingRequirements ?? ['PUBLISHING_ADAPTER'], + }) + } + channelAccount = account + } + + const delivery = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`delivery:${renderId}:${input.platform}:${channelAccount?.id ?? 'download'}`}))`) + const [existing] = await tx.select({ id: deliveries.id, status: deliveries.status }).from(deliveries).where(and( + eq(deliveries.renderId, renderId), + eq(deliveries.platform, input.platform), + channelAccount ? eq(deliveries.channelAccountId, channelAccount.id) : isNull(deliveries.channelAccountId), + inArray(deliveries.status, ['QUEUED', 'PUBLISHING', 'PUBLISHED']), + )).limit(1) + if (existing) throw errors.conflict(`A ${input.platform} delivery already exists in ${existing.status} state`) + + const [created] = await tx.insert(deliveries).values({ + renderId, + channelAccountId: channelAccount?.id, + platform: input.platform, + status: 'QUEUED', + destination: input.destination, + scheduledAt, + createdById: request.user.sub, + }).returning() + return created + }) + if (!delivery) throw new Error('Failed to create delivery') + let queued: Awaited> + try { + queued = await enqueueGeneration({ + workspaceId: row.project.workspaceId, + projectId: row.project.id, + episodeId: row.episode.id, + userId: request.user.sub, + type: 'DELIVERY_PUBLISH', + idempotencyKey: `delivery-${delivery.id}`, + payload: { deliveryId: delivery.id }, + maxAttempts: 3, + ...(scheduledAt ? { delayMs: Math.max(0, scheduledAt.getTime() - Date.now()) } : {}), + }) + } catch (error) { + await db.update(deliveries).set({ status: 'FAILED', errorMessage: 'Failed to enqueue delivery', updatedAt: new Date() }).where(eq(deliveries.id, delivery.id)) + throw error + } + const [linkedDelivery] = await db.update(deliveries).set({ + jobId: queued.job.id, + updatedAt: new Date(), + }).where(eq(deliveries.id, delivery.id)).returning() + await audit({ request, workspaceId: row.project.workspaceId, action: 'delivery.create', resourceType: 'delivery', resourceId: delivery.id, after: { platform: delivery.platform, channelAccountId: delivery.channelAccountId, jobId: queued.job.id } }) + return reply.code(202).send({ delivery: { ...(linkedDelivery ?? delivery), channelAccount: publicChannelAccount(channelAccount) }, job: queued.job }) + }) + + app.get('/deliveries/:deliveryId', async (request) => { + const { deliveryId } = parseWith(deliveryParams, request.params) + const row = await deliveryProject(deliveryId) + await requireProjectPermission(request, row.project.id, 'project:read') + const downloadable = row.delivery.platform === 'DOWNLOAD' && row.delivery.status === 'PUBLISHED' && Boolean(row.render.storageKey) + return { + ...row.delivery, + render: renderResponse(row.render), + channelAccount: publicChannelAccount(row.channelAccount), + downloadUrl: downloadable ? await presignDownload(row.render.storageKey!, 300) : null, + downloadExpiresIn: downloadable ? 300 : null, + } + }) + + app.delete('/deliveries/:deliveryId', async (request) => { + const { deliveryId } = parseWith(deliveryParams, request.params) + const row = await deliveryProject(deliveryId) + await requireProjectPermission(request, row.project.id, 'publish:create') + if (row.delivery.status !== 'QUEUED') throw errors.conflict(`Delivery in ${row.delivery.status} state cannot be cancelled`) + if (!row.delivery.jobId) throw errors.conflict('Delivery task reference is missing') + + const [job] = await db.select().from(generationJobs).where(eq(generationJobs.id, row.delivery.jobId)).limit(1) + if (!job || job.type !== 'DELIVERY_PUBLISH' || job.input.deliveryId !== deliveryId) { + throw errors.conflict('Delivery task reference is invalid') + } + if (job.status !== 'QUEUED') throw errors.conflict('Delivery has already started publishing') + + const queueJob = await generationQueue.getJob(job.id) + if (queueJob && await queueJob.isActive()) throw errors.conflict('Delivery has already started publishing') + + const cancelledAt = new Date() + const cancelled = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`delivery:${row.render.id}:${row.delivery.platform}`}))`) + const [current] = await tx.select().from(deliveries).where(eq(deliveries.id, deliveryId)).limit(1) + if (!current) throw errors.notFound('Delivery') + if (current.status !== 'QUEUED' || current.jobId !== job.id) { + throw errors.conflict(`Delivery in ${current.status} state cannot be cancelled`) + } + const [cancelledJob] = await tx.update(generationJobs).set({ + status: 'CANCELLED', + completedAt: cancelledAt, + updatedAt: cancelledAt, + }).where(and(eq(generationJobs.id, job.id), eq(generationJobs.status, 'QUEUED'))).returning() + if (!cancelledJob) throw errors.conflict('Delivery has already started publishing') + const [cancelledDelivery] = await tx.update(deliveries).set({ + status: 'CANCELLED', + errorMessage: null, + updatedAt: cancelledAt, + }).where(and(eq(deliveries.id, deliveryId), eq(deliveries.status, 'QUEUED'))).returning() + if (!cancelledDelivery) throw errors.conflict('Delivery has already started publishing') + return cancelledDelivery + }) + if (queueJob) await queueJob.remove().catch(() => undefined) + + await audit({ + request, + workspaceId: row.project.workspaceId, + action: 'delivery.cancel', + resourceType: 'delivery', + resourceId: deliveryId, + before: { status: row.delivery.status, jobId: job.id, scheduledAt: row.delivery.scheduledAt }, + after: { status: 'CANCELLED' }, + }) + return cancelled + }) + + app.post('/deliveries/:deliveryId/retry', async (request, reply) => { + const { deliveryId } = parseWith(deliveryParams, request.params) + const row = await deliveryProject(deliveryId) + await requireProjectPermission(request, row.project.id, 'publish:create') + if (row.delivery.platform !== 'DOWNLOAD') { + if (!row.channelAccount || row.channelAccount.status !== 'CONNECTED' || !row.channelAccount.accessTokenCiphertext) { + throw errors.conflict('Publishing channel account is not connected') + } + const platform = row.delivery.platform as (typeof externalPublishingPlatforms)[number] + const readiness = publishingAdapters.get(platform)?.readiness() + if (!readiness || readiness.status !== 'CONFIGURED') { + throw errors.serviceUnavailable(`${platform} publishing adapter is not configured`, { + platform, + missingRequirements: readiness?.missingRequirements ?? ['PUBLISHING_ADAPTER'], + }) + } + } + + const delivery = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`delivery:${row.render.id}:${row.delivery.platform}`}))`) + const [current] = await tx.select().from(deliveries).where(eq(deliveries.id, deliveryId)).limit(1) + if (!current) throw errors.notFound('Delivery') + if (!['FAILED', 'CANCELLED'].includes(current.status)) throw errors.conflict(`Delivery in ${current.status} state cannot be retried`) + if (row.render.status !== 'APPROVED' || !row.render.storageKey) throw errors.conflict('Render must remain approved before retrying delivery') + + const [conflicting] = await tx.select({ id: deliveries.id, status: deliveries.status }).from(deliveries).where(and( + eq(deliveries.renderId, current.renderId), + eq(deliveries.platform, current.platform), + ne(deliveries.id, current.id), + inArray(deliveries.status, ['QUEUED', 'PUBLISHING', 'PUBLISHED']), + )).limit(1) + if (conflicting) throw errors.conflict(`Another ${current.platform} delivery already exists in ${conflicting.status} state`) + + const [updated] = await tx.update(deliveries).set({ + status: 'QUEUED', + errorMessage: null, + externalId: null, + publishedUrl: null, + publishedAt: null, + scheduledAt: null, + jobId: null, + updatedAt: new Date(), + }).where(eq(deliveries.id, deliveryId)).returning() + return updated + }) + if (!delivery) throw new Error('Failed to retry delivery') + + let queued: Awaited> + try { + queued = await enqueueGeneration({ + workspaceId: row.project.workspaceId, + projectId: row.project.id, + episodeId: row.episode.id, + userId: request.user.sub, + type: 'DELIVERY_PUBLISH', + idempotencyKey: `delivery-retry-${delivery.id}-${crypto.randomUUID()}`, + payload: { deliveryId: delivery.id }, + maxAttempts: 3, + }) + } catch (error) { + await db.update(deliveries).set({ + status: 'FAILED', + errorMessage: 'Failed to enqueue delivery retry', + updatedAt: new Date(), + }).where(eq(deliveries.id, delivery.id)) + throw error + } + const [linkedDelivery] = await db.update(deliveries).set({ + jobId: queued.job.id, + updatedAt: new Date(), + }).where(eq(deliveries.id, delivery.id)).returning() + await audit({ + request, + workspaceId: row.project.workspaceId, + action: 'delivery.retry', + resourceType: 'delivery', + resourceId: delivery.id, + before: { status: row.delivery.status, errorMessage: row.delivery.errorMessage }, + after: { status: 'QUEUED', jobId: queued.job.id }, + }) + return reply.code(202).send({ delivery: linkedDelivery ?? delivery, job: queued.job }) + }) + + app.get('/deliveries/:deliveryId/download', async (request, reply) => { + const { deliveryId } = parseWith(deliveryParams, request.params) + const row = await deliveryProject(deliveryId) + await requireProjectPermission(request, row.project.id, 'project:read') + if (row.delivery.status !== 'PUBLISHED' || !row.render.storageKey) throw errors.conflict('Delivery is not available') + return reply.redirect(await presignDownload(row.render.storageKey, 300)) + }) +} diff --git a/server/src/routes/project-management.integration.test.ts b/server/src/routes/project-management.integration.test.ts new file mode 100644 index 0000000..e8e0c0b --- /dev/null +++ b/server/src/routes/project-management.integration.test.ts @@ -0,0 +1,147 @@ +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { episodes, projects, users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('project and episode management', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + let episodeId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ method, url, headers: { authorization: `Bearer ${token}` }, payload }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.statusCode === 204 ? null : response.json() + } + + async function register(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp() + owner = await register('project-owner') + viewer = await register('project-viewer') + workspaceId = owner.workspaces[0].id + + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '项目管理测试', + logline: '初始故事梗概', + genre: '都市悬疑', + visualBible: {}, + }) + projectId = project.id + const episode = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 3, + title: '初始分集', + synopsis: '初始分集梗概', + targetDurationSeconds: 90, + }) + episodeId = episode.id + await request(owner.accessToken, 'POST', `/api/v1/episodes/${episodeId}/scripts`, { + title: '初始分集', + content: { source: 'test' }, + plainText: '需要在资料更新后保留的剧本。', + changeSummary: '初始版本', + }) + }) + + afterAll(async () => { + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('updates project and episode metadata without replacing production history', async () => { + const updatedProject = await request(owner.accessToken, 'PATCH', `/api/v1/projects/${projectId}`, { + name: '更新后的项目名称', + logline: '更新后的故事梗概', + genre: '近未来悬疑', + status: 'ACTIVE', + visualBible: { styleDescription: '低饱和雨夜,写实国漫' }, + }) + expect(updatedProject).toMatchObject({ + name: '更新后的项目名称', + logline: '更新后的故事梗概', + genre: '近未来悬疑', + status: 'ACTIVE', + visualBible: { styleDescription: '低饱和雨夜,写实国漫' }, + }) + + const updatedEpisode = await request(owner.accessToken, 'PATCH', `/api/v1/episodes/${episodeId}`, { + title: '更新后的分集', + synopsis: '更新后的分集梗概', + targetDurationSeconds: 150, + }) + expect(updatedEpisode).toMatchObject({ + episodeNumber: 3, + title: '更新后的分集', + synopsis: '更新后的分集梗概', + targetDurationSeconds: 150, + }) + + const detail = await request(owner.accessToken, 'GET', `/api/v1/episodes/${episodeId}`) + expect(detail.currentScript.plainText).toBe('需要在资料更新后保留的剧本。') + expect(detail.episodeNumber).toBe(3) + }) + + it('keeps project and episode mutations unavailable to read-only members', async () => { + const projectUpdate = await inject(viewer.accessToken, 'PATCH', `/api/v1/projects/${projectId}`, { name: '越权修改' }) + expect(projectUpdate.statusCode, projectUpdate.body).toBe(403) + + const episodeUpdate = await inject(viewer.accessToken, 'PATCH', `/api/v1/episodes/${episodeId}`, { title: '越权修改' }) + expect(episodeUpdate.statusCode, episodeUpdate.body).toBe(403) + + const archive = await inject(viewer.accessToken, 'DELETE', `/api/v1/projects/${projectId}`) + expect(archive.statusCode, archive.body).toBe(403) + }) + + it('soft-archives a project while preserving its episodes and excludes it from active lists', async () => { + const archived = await request(owner.accessToken, 'DELETE', `/api/v1/projects/${projectId}`) + expect(archived.status).toBe('ARCHIVED') + + const activeProjects = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/projects`) + expect(activeProjects.some((project: { id: string }) => project.id === projectId)).toBe(false) + + const [persistedProject] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1) + const [persistedEpisode] = await db.select().from(episodes).where(eq(episodes.id, episodeId)).limit(1) + expect(persistedProject?.status).toBe('ARCHIVED') + expect(persistedEpisode?.title).toBe('更新后的分集') + }) +}) diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts new file mode 100644 index 0000000..62ffd82 --- /dev/null +++ b/server/src/routes/projects.ts @@ -0,0 +1,82 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq, ne } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { episodes, projects } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission, requireWorkspacePermission } from '../lib/permissions.js' +import { slugify } from '../lib/slug.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const projectParams = z.object({ projectId: z.uuid() }) +const createProjectSchema = z.object({ + name: z.string().trim().min(2).max(180), + logline: z.string().max(2000).default(''), + genre: z.string().trim().min(1).max(80).default('都市悬疑'), + visualBible: z.record(z.string(), z.unknown()).default({}), +}) +const updateProjectSchema = createProjectSchema.partial().extend({ + status: z.enum(['DRAFT', 'ACTIVE', 'ARCHIVED']).optional(), +}) + +export async function projectRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/projects', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'project:read') + return db.select().from(projects).where(and(eq(projects.workspaceId, workspaceId), ne(projects.status, 'ARCHIVED'))).orderBy(desc(projects.updatedAt)) + }) + + app.post('/workspaces/:workspaceId/projects', async (request, reply) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(createProjectSchema, request.body) + await requireWorkspacePermission(request, workspaceId, 'project:write') + + const [project] = await db.insert(projects).values({ + workspaceId, + createdById: request.user.sub, + name: input.name, + slug: `${slugify(input.name)}-${crypto.randomUUID().slice(0, 6)}`, + logline: input.logline, + genre: input.genre, + visualBible: input.visualBible, + status: 'DRAFT', + }).returning() + if (!project) throw new Error('Failed to create project') + await audit({ request, workspaceId, action: 'project.create', resourceType: 'project', resourceId: project.id, after: project }) + return reply.code(201).send(project) + }) + + app.get('/projects/:projectId', async (request) => { + const { projectId } = parseWith(projectParams, request.params) + const { project } = await requireProjectPermission(request, projectId, 'project:read') + const [fullProject] = await db.select().from(projects).where(eq(projects.id, project.id)).limit(1) + if (!fullProject) throw errors.notFound('Project') + const episodeRows = await db.select().from(episodes).where(eq(episodes.projectId, projectId)).orderBy(episodes.episodeNumber) + return { ...fullProject, episodes: episodeRows } + }) + + app.patch('/projects/:projectId', async (request) => { + const { projectId } = parseWith(projectParams, request.params) + const input = parseWith(updateProjectSchema, request.body) + const { project } = await requireProjectPermission(request, projectId, 'project:write') + const [before] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1) + if (!before) throw errors.notFound('Project') + const [updated] = await db.update(projects).set({ ...input, updatedAt: new Date() }).where(eq(projects.id, projectId)).returning() + if (!updated) throw errors.notFound('Project') + await audit({ request, workspaceId: project.workspaceId, action: 'project.update', resourceType: 'project', resourceId: projectId, before, after: updated }) + return updated + }) + + app.delete('/projects/:projectId', async (request, reply) => { + const { projectId } = parseWith(projectParams, request.params) + const { project } = await requireProjectPermission(request, projectId, 'project:write') + const [updated] = await db.update(projects).set({ status: 'ARCHIVED', updatedAt: new Date() }).where(eq(projects.id, projectId)).returning() + await audit({ request, workspaceId: project.workspaceId, action: 'project.archive', resourceType: 'project', resourceId: projectId, after: { status: 'ARCHIVED' } }) + return reply.code(200).send(updated) + }) +} diff --git a/server/src/routes/publishing.integration.test.ts b/server/src/routes/publishing.integration.test.ts new file mode 100644 index 0000000..cc0456c --- /dev/null +++ b/server/src/routes/publishing.integration.test.ts @@ -0,0 +1,301 @@ +import type { FastifyInstance } from 'fastify' +import { eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { + deliveries, + publishingChannelAccounts, + publishingOauthStates, + publishingWebhookEvents, + renders, + users, + workspaces, +} from '../db/schema.js' +import { connectorWebhookSignature } from '../publishing/connector.js' +import type { + PublishingAccountCredentials, + PublishingAdapter, + PublishingAdapterRegistry, + PublishingAuthorizationResult, + PublishingResult, +} from '../publishing/types.js' +import { generationQueue } from '../queue/client.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +class FakePublishingAdapter implements PublishingAdapter { + readonly platform = 'DOUYIN' as const + authorizationState = '' + revokedAccounts: string[] = [] + + readiness() { + return { status: 'CONFIGURED' as const, missingRequirements: [] } + } + + async createAuthorization(input: { state: string }): Promise { + this.authorizationState = input.state + return { + authorizationUrl: `https://open.douyin.test/oauth?state=${encodeURIComponent(input.state)}`, + codeVerifier: 'pkce-verifier-with-at-least-32-characters', + } + } + + async exchangeAuthorization(input: { code: string; codeVerifier?: string }): Promise { + expect(input.code).toBe('authorization-code-one') + expect(input.codeVerifier).toBe('pkce-verifier-with-at-least-32-characters') + return { + externalAccountId: 'douyin-creator-1001', + displayName: '漫剧创作者', + avatarUrl: 'https://images.example.cn/avatar.png', + accessToken: 'plain-access-token-must-not-leak', + refreshToken: 'plain-refresh-token-must-not-leak', + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + scopes: ['video.create', 'video.data'], + } + } + + async publish(): Promise { + return { status: 'PUBLISHING', externalId: 'provider-video-1' } + } + + async revoke(input: { externalAccountId: string }) { + this.revokedAccounts.push(input.externalAccountId) + } +} + +describe.sequential('publishing channels and provider callbacks', () => { + let app: FastifyInstance + let owner: Session + let viewer: Session + let workspaceId = '' + let projectId = '' + let episodeId = '' + let renderId = '' + let channelAccountId = '' + const webhookSecret = 'publishing-webhook-secret-with-32-characters' + const adapter = new FakePublishingAdapter() + const adapters: PublishingAdapterRegistry = new Map([['DOUYIN', adapter]]) + const userIds: string[] = [] + const workspaceIds: string[] = [] + const queueJobIds: string[] = [] + + async function inject(token: string | null, method: string, url: string, payload?: unknown, headers: Record = {}) { + return app.inject({ + method, + url, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers }, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + workspaceIds.push(session.workspaces[0]!.id) + return session + } + + beforeAll(async () => { + app = await buildApp({ publishingAdapters: adapters, publishingWebhookSecret: webhookSecret }) + owner = await registerAccount('publishing-owner') + viewer = await registerAccount('publishing-viewer') + workspaceId = owner.workspaces[0]!.id + const project = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '平台发布测试项目', + logline: '验证渠道账号和发布回执。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + const episode = await request(owner.accessToken, 'POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '渠道发布测试集', + synopsis: '', + targetDurationSeconds: 30, + }) + episodeId = episode.id + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: viewer.user.email, + role: 'VIEWER', + }) + const [render] = await db.insert(renders).values({ + episodeId, + version: 1, + status: 'APPROVED', + storageKey: `${workspaceId}/${projectId}/generated/renders/publishing.mp4`, + mimeType: 'video/mp4', + byteSize: 2048, + durationMs: 5000, + width: 1080, + height: 1920, + fps: 24, + settings: {}, + createdById: owner.user.id, + }).returning() + renderId = render!.id + }) + + afterAll(async () => { + for (const jobId of queueJobIds) { + const job = await generationQueue.getJob(jobId) + if (job && !(await job.isActive())) await job.remove().catch(() => undefined) + } + if (workspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, workspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('authorizes channel binding once and never exposes plaintext credentials', async () => { + const listed = await request(viewer.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/publishing/channels`) + expect(listed.canManage).toBe(false) + expect(listed.platforms.find((platform: { id: string }) => platform.id === 'DOUYIN').readiness.status).toBe('CONFIGURED') + + const forbidden = await inject(viewer.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/publishing/oauth/start`, { platform: 'DOUYIN' }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + + const started = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/publishing/oauth/start`, { + platform: 'DOUYIN', + returnPath: '/?page=publish', + }) + expect(started.authorizationUrl).toContain('https://open.douyin.test/oauth') + expect(adapter.authorizationState).toHaveLength(43) + const [oauthState] = await db.select().from(publishingOauthStates) + .where(eq(publishingOauthStates.workspaceId, workspaceId)).limit(1) + expect(oauthState?.stateHash).not.toBe(adapter.authorizationState) + expect(oauthState?.codeVerifierCiphertext).not.toContain('pkce-verifier') + + const callback = await inject(null, 'GET', `/api/v1/publishing/oauth/callback/douyin?state=${encodeURIComponent(adapter.authorizationState)}&code=authorization-code-one`) + expect(callback.statusCode, callback.body).toBe(302) + expect(callback.headers.location).toContain('publishing=connected') + + const [account] = await db.select().from(publishingChannelAccounts) + .where(eq(publishingChannelAccounts.workspaceId, workspaceId)).limit(1) + expect(account?.accessTokenCiphertext).not.toContain('plain-access-token') + expect(account?.refreshTokenCiphertext).not.toContain('plain-refresh-token') + channelAccountId = account!.id + + const channels = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/publishing/channels`) + const publicAccount = channels.platforms.find((platform: { id: string }) => platform.id === 'DOUYIN').accounts[0] + expect(publicAccount).toMatchObject({ id: channelAccountId, displayName: '漫剧创作者', status: 'CONNECTED' }) + expect(JSON.stringify(publicAccount)).not.toContain('Ciphertext') + expect(JSON.stringify(publicAccount)).not.toContain('plain-access-token') + + const replay = await inject(null, 'GET', `/api/v1/publishing/oauth/callback/douyin?state=${encodeURIComponent(adapter.authorizationState)}&code=authorization-code-one`) + expect(replay.statusCode, replay.body).toBe(400) + }) + + it('requires a matching connected channel and supports isolated scheduled delivery', async () => { + const missingAccount = await inject(owner.accessToken, 'POST', `/api/v1/renders/${renderId}/deliveries`, { platform: 'DOUYIN' }) + expect(missingAccount.statusCode, missingAccount.body).toBe(400) + + const createdResponse = await inject(owner.accessToken, 'POST', `/api/v1/renders/${renderId}/deliveries`, { + platform: 'DOUYIN', + channelAccountId, + destination: { title: '渠道发布测试集', visibility: 'PUBLIC' }, + scheduledAt: new Date(Date.now() + 30 * 60_000).toISOString(), + }) + expect(createdResponse.statusCode, createdResponse.body).toBe(202) + const created = createdResponse.json() + queueJobIds.push(created.job.id) + expect(created.delivery.channelAccount).toMatchObject({ id: channelAccountId, platform: 'DOUYIN' }) + + const forbidden = await inject(viewer.accessToken, 'POST', `/api/v1/renders/${renderId}/deliveries`, { + platform: 'DOUYIN', + channelAccountId, + }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + const cancelled = await request(owner.accessToken, 'DELETE', `/api/v1/deliveries/${created.delivery.id}`) + expect(cancelled.status).toBe('CANCELLED') + }) + + it('applies signed callbacks idempotently and ignores stale terminal regressions', async () => { + const [delivery] = await db.insert(deliveries).values({ + renderId, + channelAccountId, + platform: 'DOUYIN', + status: 'PUBLISHING', + externalId: 'provider-video-callback', + createdById: owner.user.id, + }).returning() + const occurredAt = new Date().toISOString() + const event = { + eventId: `douyin-event-published-${delivery!.id}`, + platform: 'DOUYIN', + deliveryId: delivery!.id, + externalId: 'provider-video-callback', + status: 'PUBLISHED', + occurredAt, + publishedUrl: 'https://www.douyin.com/video/provider-video-callback', + } + const payload = JSON.stringify(event) + const published = await inject(null, 'POST', '/api/v1/publishing/webhooks/connector', payload, { + 'content-type': 'application/json', + 'x-frameflow-signature': connectorWebhookSignature(payload, webhookSecret), + }) + expect(published.statusCode, published.body).toBe(200) + expect(published.json()).toEqual({ received: true, duplicate: false, applied: true }) + + const duplicate = await inject(null, 'POST', '/api/v1/publishing/webhooks/connector', payload, { + 'content-type': 'application/json', + 'x-frameflow-signature': connectorWebhookSignature(payload, webhookSecret), + }) + expect(duplicate.json()).toEqual({ received: true, duplicate: true, applied: false }) + + const stalePayload = JSON.stringify({ + ...event, + eventId: `douyin-event-stale-${delivery!.id}`, + status: 'FAILED', + occurredAt: new Date(Date.parse(occurredAt) - 10_000).toISOString(), + errorMessage: 'late provider timeout', + }) + const stale = await inject(null, 'POST', '/api/v1/publishing/webhooks/connector', stalePayload, { + 'content-type': 'application/json', + 'x-frameflow-signature': connectorWebhookSignature(stalePayload, webhookSecret), + }) + expect(stale.json()).toEqual({ received: true, duplicate: false, applied: false }) + const [stored] = await db.select().from(deliveries).where(eq(deliveries.id, delivery!.id)).limit(1) + expect(stored).toMatchObject({ status: 'PUBLISHED', publishedUrl: event.publishedUrl, errorMessage: null }) + const events = await db.select().from(publishingWebhookEvents).where(eq(publishingWebhookEvents.deliveryId, delivery!.id)) + expect(events).toHaveLength(2) + expect(events.filter((row) => row.applied)).toHaveLength(1) + + const invalid = await inject(null, 'POST', '/api/v1/publishing/webhooks/connector', payload, { + 'content-type': 'application/json', + 'x-frameflow-signature': 'sha256=invalid', + }) + expect(invalid.statusCode, invalid.body).toBe(400) + }) + + it('revokes the provider grant and removes stored credentials', async () => { + const revoked = await request(owner.accessToken, 'DELETE', `/api/v1/publishing/channels/${channelAccountId}`) + expect(revoked.status).toBe('REVOKED') + expect(adapter.revokedAccounts).toEqual(['douyin-creator-1001']) + const [stored] = await db.select().from(publishingChannelAccounts) + .where(eq(publishingChannelAccounts.id, channelAccountId)).limit(1) + expect(stored?.accessTokenCiphertext).toBeNull() + expect(stored?.refreshTokenCiphertext).toBeNull() + }) +}) diff --git a/server/src/routes/publishing.ts b/server/src/routes/publishing.ts new file mode 100644 index 0000000..07d36e1 --- /dev/null +++ b/server/src/routes/publishing.ts @@ -0,0 +1,392 @@ +import { createHash, randomBytes } from 'node:crypto' +import type { FastifyInstance, FastifyRequest } from 'fastify' +import { and, eq, lt } from 'drizzle-orm' +import { z } from 'zod' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { + deliveries, + episodes, + projects, + publishingChannelAccounts, + publishingOauthStates, + publishingWebhookEvents, + renders, +} from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { decryptCredential, encryptCredential } from '../lib/credential-vault.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { createNotification } from '../lib/notifications.js' +import { requireWorkspacePermission } from '../lib/permissions.js' +import { publishRealtimeEvent } from '../lib/realtime.js' +import { + createPublishingAdapterRegistry, + verifyConnectorWebhookSignature, +} from '../publishing/connector.js' +import { + externalPublishingPlatforms, + publishingPlatformDetails, + type ExternalPublishingPlatform, + type PublishingAdapterRegistry, +} from '../publishing/types.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const channelParams = z.object({ channelAccountId: z.uuid() }) +const platformParams = z.object({ + platform: z.string().transform((value) => value.toUpperCase()).pipe(z.enum(externalPublishingPlatforms)), +}) +const startOauthSchema = z.object({ + platform: z.enum(externalPublishingPlatforms), + returnPath: z.string().max(500).refine((value) => value.startsWith('/') && !value.startsWith('//'), 'returnPath must be an application-relative path').default('/?publishing=connected'), +}) +const oauthCallbackQuery = z.object({ + state: z.string().min(32).max(512), + code: z.string().min(1).max(4096).optional(), + error: z.string().max(255).optional(), + error_description: z.string().max(1000).optional(), +}) +export const publishingWebhookSchema = z.object({ + eventId: z.string().min(1).max(255), + platform: z.enum(externalPublishingPlatforms), + deliveryId: z.uuid(), + externalId: z.string().min(1).max(255), + status: z.enum(['PUBLISHING', 'PUBLISHED', 'FAILED']), + occurredAt: z.iso.datetime({ offset: true }), + publishedUrl: z.url().optional(), + errorMessage: z.string().max(4000).optional(), +}) + +export type PublishingWebhookInput = z.infer + +interface PublishingRouteOptions { + publishingAdapters?: PublishingAdapterRegistry + publishingWebhookSecret?: string +} + +function stateHash(state: string) { + return createHash('sha256').update(state).digest('hex') +} + +function platformCallbackUrl(platform: ExternalPublishingPlatform) { + return new URL(`/api/v1/publishing/oauth/callback/${platform.toLowerCase()}`, `${config.API_PUBLIC_ORIGIN}/`).toString() +} + +function browserRedirect(returnPath: string, outcome: 'connected' | 'error') { + const origin = config.WEB_ORIGIN.split(',')[0]?.trim() || 'http://127.0.0.1:4173' + const target = new URL(returnPath, `${origin}/`) + target.searchParams.set('publishing', outcome) + return target.toString() +} + +function publicChannelAccount(account: typeof publishingChannelAccounts.$inferSelect) { + return { + id: account.id, + workspaceId: account.workspaceId, + platform: account.platform, + externalAccountId: account.externalAccountId, + displayName: account.displayName, + avatarUrl: account.avatarUrl, + status: account.status, + tokenExpiresAt: account.tokenExpiresAt, + scopes: account.scopes, + lastVerifiedAt: account.lastVerifiedAt, + errorCode: account.errorCode, + errorMessage: account.errorMessage, + connectedById: account.connectedById, + createdAt: account.createdAt, + updatedAt: account.updatedAt, + } +} + +function publishingTransitionAllowed(current: typeof deliveries.$inferSelect.status, next: PublishingWebhookInput['status']) { + if (current === 'QUEUED') return true + if (current === 'PUBLISHING') return true + if (current === 'PUBLISHED') return next === 'PUBLISHED' + if (current === 'FAILED') return next === 'FAILED' + return false +} + +export async function applyPublishingWebhookEvent(input: PublishingWebhookInput) { + const occurredAt = new Date(input.occurredAt) + const eventKey = `${input.platform}:${input.eventId}` + + return db.transaction(async (tx) => { + const [row] = await tx.select({ + delivery: deliveries, + episode: episodes, + project: projects, + }).from(deliveries) + .innerJoin(renders, eq(renders.id, deliveries.renderId)) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(deliveries.id, input.deliveryId)).limit(1) + if (!row) throw errors.notFound('Delivery') + + const [inserted] = await tx.insert(publishingWebhookEvents).values({ + eventKey, + eventId: input.eventId, + workspaceId: row.project.workspaceId, + platform: input.platform, + deliveryId: input.deliveryId, + status: input.status, + providerOccurredAt: occurredAt, + payload: input, + }).onConflictDoNothing().returning({ eventKey: publishingWebhookEvents.eventKey }) + if (!inserted) return { duplicate: true, applied: false, workspaceId: row.project.workspaceId, delivery: row.delivery } + + const platformMatches = row.delivery.platform === input.platform + const externalIdMatches = !row.delivery.externalId || row.delivery.externalId === input.externalId + const eventIsCurrent = !row.delivery.lastProviderEventAt || row.delivery.lastProviderEventAt <= occurredAt + const transitionAllowed = publishingTransitionAllowed(row.delivery.status, input.status) + const applied = platformMatches && externalIdMatches && eventIsCurrent && transitionAllowed + if (!applied) { + return { duplicate: false, applied: false, workspaceId: row.project.workspaceId, delivery: row.delivery } + } + + const [delivery] = await tx.update(deliveries).set({ + status: input.status, + externalId: input.externalId, + publishedUrl: input.status === 'PUBLISHED' ? input.publishedUrl ?? row.delivery.publishedUrl : row.delivery.publishedUrl, + publishedAt: input.status === 'PUBLISHED' ? occurredAt : row.delivery.publishedAt, + errorMessage: input.status === 'FAILED' ? input.errorMessage ?? '平台发布失败' : null, + lastProviderEventAt: occurredAt, + updatedAt: new Date(), + }).where(eq(deliveries.id, input.deliveryId)).returning() + await tx.update(publishingWebhookEvents).set({ applied: true }).where(eq(publishingWebhookEvents.eventKey, eventKey)) + if (input.status === 'PUBLISHED') { + await tx.update(episodes).set({ status: 'PUBLISHED', updatedAt: new Date() }).where(eq(episodes.id, row.episode.id)) + } + return { duplicate: false, applied: true, workspaceId: row.project.workspaceId, delivery: delivery ?? row.delivery } + }) +} + +async function notifyPublishingCallback(result: Awaited>) { + if (!result.applied) return + await publishRealtimeEvent({ + workspaceId: result.workspaceId, + type: 'delivery.updated', + resourceType: 'delivery', + resourceId: result.delivery.id, + payload: { + id: result.delivery.id, + platform: result.delivery.platform, + status: result.delivery.status, + publishedUrl: result.delivery.publishedUrl, + errorMessage: result.delivery.errorMessage, + }, + }) + if (!['PUBLISHED', 'FAILED'].includes(result.delivery.status)) return + const published = result.delivery.status === 'PUBLISHED' + await createNotification({ + workspaceId: result.workspaceId, + userId: result.delivery.createdById, + kind: published ? 'DELIVERY_PUBLISHED' : 'DELIVERY_FAILED', + tone: published ? 'SUCCESS' : 'ERROR', + title: published ? '平台发布成功' : '平台发布失败', + body: published ? '平台已确认接收并发布成片。' : result.delivery.errorMessage ?? '平台返回发布失败,请检查渠道账号后重试。', + page: 'publish', + resourceType: 'delivery', + resourceId: result.delivery.id, + dedupeKey: `delivery:${result.delivery.id}:${result.delivery.status}`, + metadata: { platform: result.delivery.platform, status: result.delivery.status }, + }) +} + +export async function publishingRoutes(app: FastifyInstance, options: PublishingRouteOptions = {}) { + const adapters = options.publishingAdapters ?? createPublishingAdapterRegistry() + const webhookSecret = options.publishingWebhookSecret ?? config.PUBLISHING_CONNECTOR_SECRET + + app.get('/workspaces/:workspaceId/publishing/channels', { preHandler: requireAuth }, async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const membership = await requireWorkspacePermission(request, workspaceId, 'project:read') + const accounts = await db.select().from(publishingChannelAccounts) + .where(eq(publishingChannelAccounts.workspaceId, workspaceId)) + return { + canManage: ['OWNER', 'ADMIN'].includes(membership.role), + platforms: externalPublishingPlatforms.map((platform) => ({ + id: platform, + ...publishingPlatformDetails[platform], + readiness: adapters.get(platform)?.readiness() ?? { status: 'NOT_CONFIGURED', missingRequirements: ['PUBLISHING_ADAPTER'] }, + accounts: accounts.filter((account) => account.platform === platform).map(publicChannelAccount), + })), + } + }) + + app.post('/workspaces/:workspaceId/publishing/oauth/start', { preHandler: requireAuth }, async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(startOauthSchema, request.body) + await requireWorkspacePermission(request, workspaceId, 'publishing:manage') + const adapter = adapters.get(input.platform) + if (!adapter) throw errors.serviceUnavailable(`${input.platform} publishing adapter is unavailable`) + + await db.delete(publishingOauthStates).where(lt(publishingOauthStates.expiresAt, new Date())) + const state = randomBytes(32).toString('base64url') + const callbackUrl = platformCallbackUrl(input.platform) + const authorization = await adapter.createAuthorization({ state, callbackUrl }) + const expiresAt = new Date(Date.now() + 10 * 60_000) + await db.insert(publishingOauthStates).values({ + stateHash: stateHash(state), + workspaceId, + platform: input.platform, + codeVerifierCiphertext: authorization.codeVerifier ? encryptCredential(authorization.codeVerifier) : null, + returnPath: input.returnPath, + createdById: request.user.sub, + expiresAt, + }) + await audit({ + request, + workspaceId, + action: 'publishing.oauth.start', + resourceType: 'publishing_channel_account', + after: { platform: input.platform, expiresAt }, + }) + return { authorizationUrl: authorization.authorizationUrl, expiresAt } + }) + + app.get('/publishing/oauth/callback/:platform', async (request, reply) => { + const { platform } = parseWith(platformParams, request.params) + const input = parseWith(oauthCallbackQuery, request.query) + const [state] = await db.select().from(publishingOauthStates) + .where(eq(publishingOauthStates.stateHash, stateHash(input.state))).limit(1) + if (!state || state.platform !== platform || state.consumedAt || state.expiresAt <= new Date()) { + throw errors.badRequest('OAuth state is invalid or expired') + } + const [consumed] = await db.update(publishingOauthStates).set({ consumedAt: new Date() }).where(and( + eq(publishingOauthStates.stateHash, state.stateHash), + eq(publishingOauthStates.platform, platform), + )).returning() + if (!consumed) throw errors.badRequest('OAuth state has already been consumed') + if (input.error || !input.code) { + await audit({ + request, + workspaceId: state.workspaceId, + action: 'publishing.oauth.failed', + resourceType: 'publishing_channel_account', + after: { platform, error: input.error ?? 'authorization_code_missing' }, + }) + return reply.redirect(browserRedirect(state.returnPath, 'error')) + } + + const adapter = adapters.get(platform) + if (!adapter) throw errors.serviceUnavailable(`${platform} publishing adapter is unavailable`) + let credentials + try { + credentials = await adapter.exchangeAuthorization({ + code: input.code, + callbackUrl: platformCallbackUrl(platform), + ...(state.codeVerifierCiphertext ? { codeVerifier: decryptCredential(state.codeVerifierCiphertext) } : {}), + }) + } catch (error) { + request.log.error({ err: error, workspaceId: state.workspaceId, platform }, 'Publishing OAuth exchange failed') + return reply.redirect(browserRedirect(state.returnPath, 'error')) + } + + const values = { + displayName: credentials.displayName, + avatarUrl: credentials.avatarUrl ?? null, + status: 'CONNECTED' as const, + accessTokenCiphertext: encryptCredential(credentials.accessToken), + refreshTokenCiphertext: credentials.refreshToken ? encryptCredential(credentials.refreshToken) : null, + tokenExpiresAt: credentials.tokenExpiresAt ? new Date(credentials.tokenExpiresAt) : null, + scopes: credentials.scopes ?? [], + metadata: credentials.metadata ?? {}, + connectedById: state.createdById, + lastVerifiedAt: new Date(), + errorCode: null, + errorMessage: null, + updatedAt: new Date(), + } + const [account] = await db.insert(publishingChannelAccounts).values({ + workspaceId: state.workspaceId, + platform, + externalAccountId: credentials.externalAccountId, + ...values, + }).onConflictDoUpdate({ + target: [publishingChannelAccounts.workspaceId, publishingChannelAccounts.platform, publishingChannelAccounts.externalAccountId], + set: values, + }).returning() + if (!account) throw new Error('Failed to save publishing channel account') + await audit({ + request, + workspaceId: state.workspaceId, + action: 'publishing.channel.connect', + resourceType: 'publishing_channel_account', + resourceId: account.id, + after: { platform, externalAccountId: account.externalAccountId, displayName: account.displayName }, + }) + await publishRealtimeEvent({ + workspaceId: state.workspaceId, + type: 'publishing.channel.updated', + resourceType: 'publishing_channel_account', + resourceId: account.id, + payload: { account: publicChannelAccount(account) }, + }) + return reply.redirect(browserRedirect(state.returnPath, 'connected')) + }) + + app.delete('/publishing/channels/:channelAccountId', { preHandler: requireAuth }, async (request) => { + const { channelAccountId } = parseWith(channelParams, request.params) + const [account] = await db.select().from(publishingChannelAccounts) + .where(eq(publishingChannelAccounts.id, channelAccountId)).limit(1) + if (!account) throw errors.notFound('Publishing channel account') + await requireWorkspacePermission(request, account.workspaceId, 'publishing:manage') + if (account.status === 'REVOKED') return publicChannelAccount(account) + const adapter = adapters.get(account.platform as ExternalPublishingPlatform) + if (!adapter || !account.accessTokenCiphertext) throw errors.serviceUnavailable(`${account.platform} publishing adapter is unavailable`) + await adapter.revoke({ + externalAccountId: account.externalAccountId, + accessToken: decryptCredential(account.accessTokenCiphertext), + ...(account.refreshTokenCiphertext ? { refreshToken: decryptCredential(account.refreshTokenCiphertext) } : {}), + }) + const [revoked] = await db.update(publishingChannelAccounts).set({ + status: 'REVOKED', + accessTokenCiphertext: null, + refreshTokenCiphertext: null, + tokenExpiresAt: null, + updatedAt: new Date(), + }).where(eq(publishingChannelAccounts.id, account.id)).returning() + await audit({ + request, + workspaceId: account.workspaceId, + action: 'publishing.channel.revoke', + resourceType: 'publishing_channel_account', + resourceId: account.id, + before: { platform: account.platform, status: account.status }, + after: { platform: account.platform, status: 'REVOKED' }, + }) + await publishRealtimeEvent({ + workspaceId: account.workspaceId, + type: 'publishing.channel.updated', + resourceType: 'publishing_channel_account', + resourceId: account.id, + payload: { account: publicChannelAccount(revoked ?? account) }, + }) + return publicChannelAccount(revoked ?? account) + }) + + app.post('/publishing/webhooks/connector', { + bodyLimit: 1024 * 1024, + config: { rawBody: true, rateLimit: { max: 600, timeWindow: '1 minute' } }, + }, async (request: FastifyRequest) => { + const signature = request.headers['x-frameflow-signature'] + if (!Buffer.isBuffer(request.rawBody) || typeof signature !== 'string' || !verifyConnectorWebhookSignature(request.rawBody, signature, webhookSecret)) { + throw errors.badRequest('Publishing webhook signature is invalid') + } + const event = parseWith(publishingWebhookSchema, request.body) + const result = await applyPublishingWebhookEvent(event) + await notifyPublishingCallback(result) + if (!result.duplicate && result.applied) { + await audit({ + request, + workspaceId: result.workspaceId, + action: 'publishing.webhook.process', + resourceType: 'delivery', + resourceId: result.delivery.id, + after: { eventId: event.eventId, platform: event.platform, status: event.status }, + }) + } + return { received: true, duplicate: result.duplicate, applied: result.applied } + }) +} diff --git a/server/src/routes/readiness.integration.test.ts b/server/src/routes/readiness.integration.test.ts new file mode 100644 index 0000000..0470b79 --- /dev/null +++ b/server/src/routes/readiness.integration.test.ts @@ -0,0 +1,292 @@ +import type { FastifyInstance } from 'fastify' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { buildApp } from '../app.js' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { auditLogs, providerVerifications, users, workspaces } from '../db/schema.js' +import type { ProviderConfiguration } from '../providers/readiness.js' +import { publishWorkerHeartbeat } from '../queue/heartbeat.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +const verificationConfiguration: ProviderConfiguration = { + openaiApiKey: 'readiness-openai-secret', + openaiTextModel: 'readiness-text-model', + openaiImageModel: 'readiness-image-model', + openaiTtsModel: 'readiness-tts-model', +} + +describe.sequential('service readiness API', () => { + let app: FastifyInstance + let owner: Session + let editor: Session + let outsider: Session + let verificationResponse: 'success' | 'unauthorized' = 'success' + const userIds: string[] = [] + const workspaceIds: string[] = [] + const providerFetch = vi.fn(async (url: string | URL | Request) => { + if (verificationResponse === 'unauthorized') { + return new Response('readiness-openai-secret must never be persisted', { status: 401 }) + } + const model = decodeURIComponent(new URL(String(url)).pathname.split('/').at(-1)!) + return new Response(JSON.stringify({ id: model, created: 1, object: 'model', owned_by: 'openai' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + }) + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + workspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp({ + providerVerificationFetch: providerFetch as typeof fetch, + providerVerificationConfiguration: verificationConfiguration, + }) + owner = await registerAccount('readiness-owner') + editor = await registerAccount('readiness-editor') + outsider = await registerAccount('readiness-outsider') + const membership = await app.inject({ + method: 'POST', + url: `/api/v1/workspaces/${owner.workspaces[0].id}/members`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + payload: { email: editor.user.email, role: 'EDITOR' }, + }) + expect(membership.statusCode, membership.body).toBe(201) + }) + + afterAll(async () => { + if (workspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, workspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('allows authenticated browser mutation methods through CORS preflight', async () => { + const origin = config.WEB_ORIGIN.split(',')[0]?.trim() + const response = await app.inject({ + method: 'OPTIONS', + url: `/api/v1/shots/${crypto.randomUUID()}/production`, + headers: { + origin, + 'access-control-request-method': 'PATCH', + 'access-control-request-headers': 'authorization,content-type', + }, + }) + expect(response.statusCode, response.body).toBe(204) + expect(response.headers['access-control-allow-origin']).toBe(origin) + expect(response.headers['access-control-allow-methods']).toContain('PATCH') + expect(response.headers['access-control-allow-methods']).toContain('DELETE') + }) + + it('requires authentication and workspace membership', async () => { + const workspaceId = owner.workspaces[0].id + const anonymous = await app.inject({ method: 'GET', url: `/api/v1/workspaces/${workspaceId}/service-readiness` }) + expect(anonymous.statusCode, anonymous.body).toBe(401) + + const forbidden = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${workspaceId}/service-readiness`, + headers: { authorization: `Bearer ${outsider.accessToken}` }, + }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + }) + + it('reports structured API readiness when every dependency is available', async () => { + await publishWorkerHeartbeat({ instanceId: 'readiness-integration-test', ttlSeconds: 5 }) + const response = await app.inject({ method: 'GET', url: '/health/ready' }) + expect(response.statusCode, response.body).toBe(200) + expect(response.json()).toEqual({ + status: 'ready', + dependencies: { + postgres: 'ok', + redis: 'ok', + objectStorage: 'ok', + worker: 'ok', + }, + }) + }) + + it('returns live infrastructure and redacted provider configuration status', async () => { + const response = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${owner.workspaces[0].id}/service-readiness`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(response.statusCode, response.body).toBe(200) + const body = response.json() + expect(body.checkedAt).toBeTruthy() + expect(body.infrastructure.services.map((item: { id: string }) => item.id)).toEqual([ + 'postgres', + 'redis', + 'worker', + 'object-storage', + 'ffmpeg', + ]) + expect(body.ai.verificationMode).toBe('CONFIGURATION') + expect(body.ai.workflows).toHaveLength(6) + expect(body.ai.providers.find((provider: { id: string }) => provider.id === 'openai').verification).toBe('CONFIGURATION_ONLY') + expect(response.body).not.toContain(verificationConfiguration.openaiApiKey) + if (config.OPENAI_API_KEY) expect(response.body).not.toContain(config.OPENAI_API_KEY) + if (config.REPLICATE_API_TOKEN) expect(response.body).not.toContain(config.REPLICATE_API_TOKEN) + }) + + it('restricts provider verification to workspace owners and administrators', async () => { + const workspaceId = owner.workspaces[0].id + const url = `/api/v1/workspaces/${workspaceId}/service-readiness/providers/openai/verify` + const callsBefore = providerFetch.mock.calls.length + const anonymous = await app.inject({ method: 'POST', url }) + expect(anonymous.statusCode, anonymous.body).toBe(401) + const forbiddenOutsider = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${outsider.accessToken}` }, + }) + expect(forbiddenOutsider.statusCode, forbiddenOutsider.body).toBe(403) + const forbiddenEditor = await app.inject({ + method: 'POST', + url, + headers: { authorization: `Bearer ${editor.accessToken}` }, + }) + expect(forbiddenEditor.statusCode, forbiddenEditor.body).toBe(403) + expect(providerFetch).toHaveBeenCalledTimes(callsBefore) + }) + + it('does not call an unconfigured provider', async () => { + const callsBefore = providerFetch.mock.calls.length + const response = await app.inject({ + method: 'POST', + url: `/api/v1/workspaces/${owner.workspaces[0].id}/service-readiness/providers/replicate/verify`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(response.statusCode, response.body).toBe(400) + expect(response.json().error.details).toEqual({ provider: 'replicate' }) + expect(providerFetch).toHaveBeenCalledTimes(callsBefore) + }) + + it('persists successful owner verification and returns it from later readiness reads', async () => { + verificationResponse = 'success' + const workspaceId = owner.workspaces[0].id + const response = await app.inject({ + method: 'POST', + url: `/api/v1/workspaces/${workspaceId}/service-readiness/providers/openai/verify`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(response.statusCode, response.body).toBe(200) + const provider = response.json().ai.providers.find((item: { id: string }) => item.id === 'openai') + expect(provider).toMatchObject({ + verification: 'VERIFIED', + errorCode: null, + details: { + scope: 'credentials_and_models', + models: ['readiness-text-model', 'readiness-image-model', 'readiness-tts-model'], + }, + }) + expect(provider.checkedAt).toBeTruthy() + expect(provider.verifiedAt).toBe(provider.checkedAt) + expect(response.body).not.toContain(verificationConfiguration.openaiApiKey) + + const later = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${workspaceId}/service-readiness`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(later.statusCode, later.body).toBe(200) + expect(later.json().ai.verificationMode).toBe('PERSISTED') + expect(later.json().ai.providers.find((item: { id: string }) => item.id === 'openai').verification).toBe('VERIFIED') + }) + + it('allows an administrator to verify and records a sanitized audit event', async () => { + const workspaceId = owner.workspaces[0].id + const promoted = await app.inject({ + method: 'PATCH', + url: `/api/v1/workspaces/${workspaceId}/members/${editor.user.id}`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + payload: { role: 'ADMIN' }, + }) + expect(promoted.statusCode, promoted.body).toBe(200) + const response = await app.inject({ + method: 'POST', + url: `/api/v1/workspaces/${workspaceId}/service-readiness/providers/openai/verify`, + headers: { authorization: `Bearer ${editor.accessToken}` }, + }) + expect(response.statusCode, response.body).toBe(200) + expect(response.json().ai.providers.find((item: { id: string }) => item.id === 'openai').verification).toBe('VERIFIED') + + const auditResponse = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${workspaceId}/audit-logs?category=service`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(auditResponse.statusCode, auditResponse.body).toBe(200) + expect(auditResponse.json().items.some((item: { action: string; actorId: string }) => item.action === 'service.provider.verify' && item.actorId === editor.user.id)).toBe(true) + expect(auditResponse.body).not.toContain(verificationConfiguration.openaiApiKey) + }) + + it('persists provider failures without storing tokens or raw response bodies', async () => { + verificationResponse = 'unauthorized' + const workspaceId = owner.workspaces[0].id + const response = await app.inject({ + method: 'POST', + url: `/api/v1/workspaces/${workspaceId}/service-readiness/providers/openai/verify`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(response.statusCode, response.body).toBe(200) + const provider = response.json().ai.providers.find((item: { id: string }) => item.id === 'openai') + expect(provider).toMatchObject({ + verification: 'FAILED', + errorCode: 'AUTHENTICATION_FAILED', + verifiedAt: null, + }) + expect(response.body).not.toContain(verificationConfiguration.openaiApiKey) + expect(response.body).not.toContain('must never be persisted') + + const [stored] = await db.select().from(providerVerifications).where(and( + eq(providerVerifications.workspaceId, workspaceId), + eq(providerVerifications.provider, 'openai'), + )).limit(1) + expect(stored).toMatchObject({ status: 'FAILED', errorCode: 'AUTHENTICATION_FAILED' }) + expect(JSON.stringify(stored)).not.toContain(verificationConfiguration.openaiApiKey) + expect(JSON.stringify(stored)).not.toContain('must never be persisted') + + const later = await app.inject({ + method: 'GET', + url: `/api/v1/workspaces/${workspaceId}/service-readiness`, + headers: { authorization: `Bearer ${owner.accessToken}` }, + }) + expect(later.statusCode, later.body).toBe(200) + expect(later.json().ai.providers.find((item: { id: string }) => item.id === 'openai')).toMatchObject({ + verification: 'FAILED', + errorCode: 'AUTHENTICATION_FAILED', + }) + + const [auditRow] = await db.select().from(auditLogs).where(and( + eq(auditLogs.workspaceId, workspaceId), + eq(auditLogs.action, 'service.provider.verify'), + eq(auditLogs.actorId, owner.user.id), + eq(auditLogs.resourceId, stored.id), + sql`${auditLogs.after} ->> 'status' = 'FAILED'`, + )).limit(1) + expect(auditRow?.after).toMatchObject({ status: 'FAILED', errorCode: 'AUTHENTICATION_FAILED' }) + }) +}) diff --git a/server/src/routes/readiness.ts b/server/src/routes/readiness.ts new file mode 100644 index 0000000..8c7f8ac --- /dev/null +++ b/server/src/routes/readiness.ts @@ -0,0 +1,193 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import type { FastifyInstance } from 'fastify' +import { eq, sql } from 'drizzle-orm' +import { z } from 'zod' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { providerVerifications } from '../db/schema.js' +import { audit } from '../lib/audit.js' +import { requireAuth } from '../lib/auth.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireWorkspacePermission } from '../lib/permissions.js' +import { buildProviderReadiness, providerConfiguration, type ProviderConfiguration } from '../providers/readiness.js' +import { + providerCredentialConfigured, + providerIds, + providerVerificationFingerprint, + verifyProviderConnection, +} from '../providers/verification.js' +import { checkRedis } from '../queue/connection.js' +import { checkWorkerHeartbeat } from '../queue/heartbeat.js' +import { checkStorage } from '../storage/client.js' + +const executeFile = promisify(execFile) +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const providerParams = workspaceParams.extend({ providerId: z.enum(providerIds) }) + +export interface ReadinessRoutesOptions { + providerVerificationFetch?: typeof fetch + providerVerificationConfiguration?: ProviderConfiguration +} + +async function liveCheck(id: string, label: string, check: () => Promise) { + const startedAt = performance.now() + try { + await check() + return { id, label, status: 'AVAILABLE' as const, latencyMs: Math.max(0, Math.round(performance.now() - startedAt)) } + } catch { + return { id, label, status: 'UNAVAILABLE' as const, latencyMs: null } + } +} + +function mergeProviderVerification( + readiness: ReturnType, + rows: Array, + configuration: ProviderConfiguration, +) { + const providers = readiness.providers.map((provider) => { + if (!providerCredentialConfigured(provider.id, configuration)) { + return { + ...provider, + verification: 'NOT_RUN' as const, + checkedAt: null, + verifiedAt: null, + latencyMs: null, + errorCode: null, + message: null, + details: {}, + } + } + const fingerprint = providerVerificationFingerprint(provider.id, configuration) + const row = rows.find((candidate) => candidate.provider === provider.id && candidate.configurationFingerprint === fingerprint) + if (!row) { + return { + ...provider, + verification: 'CONFIGURATION_ONLY' as const, + checkedAt: null, + verifiedAt: null, + latencyMs: null, + errorCode: null, + message: null, + details: {}, + } + } + return { + ...provider, + verification: row.status, + checkedAt: row.checkedAt.toISOString(), + verifiedAt: row.status === 'VERIFIED' ? row.checkedAt.toISOString() : null, + latencyMs: row.latencyMs, + errorCode: row.errorCode, + message: row.message, + details: row.details, + } + }) + const hasPersistedVerification = providers.some((provider) => provider.verification === 'VERIFIED' || provider.verification === 'FAILED') + return { + ...readiness, + verificationMode: hasPersistedVerification ? 'PERSISTED' as const : 'CONFIGURATION' as const, + providers, + } +} + +async function readinessPayload(workspaceId: string, configuration: ProviderConfiguration) { + const [infrastructure, verificationRows] = await Promise.all([ + Promise.all([ + liveCheck('postgres', 'PostgreSQL', () => db.execute(sql`select 1`)), + liveCheck('redis', 'Redis 任务队列', checkRedis), + liveCheck('worker', '生成任务 Worker', checkWorkerHeartbeat), + liveCheck('object-storage', '对象存储', checkStorage), + liveCheck('ffmpeg', 'FFmpeg 合成', () => executeFile(config.FFMPEG_PATH, ['-version'], { timeout: 5000, maxBuffer: 256 * 1024 })), + ]), + db.select().from(providerVerifications).where(eq(providerVerifications.workspaceId, workspaceId)), + ]) + const availableInfrastructure = infrastructure.filter((item) => item.status === 'AVAILABLE').length + return { + checkedAt: new Date().toISOString(), + infrastructure: { + status: availableInfrastructure === infrastructure.length ? 'AVAILABLE' as const : 'DEGRADED' as const, + available: availableInfrastructure, + total: infrastructure.length, + services: infrastructure, + }, + ai: mergeProviderVerification(buildProviderReadiness(configuration), verificationRows, configuration), + } +} + +export async function readinessRoutes(app: FastifyInstance, options: ReadinessRoutesOptions = {}) { + const configuration = options.providerVerificationConfiguration ?? providerConfiguration() + const verificationFetch = options.providerVerificationFetch ?? fetch + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/service-readiness', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'project:read') + return readinessPayload(workspaceId, configuration) + }) + + app.post('/workspaces/:workspaceId/service-readiness/providers/:providerId/verify', { + config: { rateLimit: { max: 10, timeWindow: '1 minute' } }, + }, async (request) => { + const { workspaceId, providerId } = parseWith(providerParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'services:verify') + if (!providerCredentialConfigured(providerId, configuration)) { + const requirement = providerId === 'openai' + ? 'OPENAI_API_KEY' + : providerId === 'replicate' + ? 'REPLICATE_API_TOKEN' + : providerId === 'agnes' + ? 'AGNES_API_KEY' + : 'GENERIC_TEXT_API_KEY、GENERIC_IMAGE_API_KEY 或 GENERIC_VIDEO_API_KEY' + throw errors.badRequest(`${requirement} 尚未配置`, { + provider: providerId, + }) + } + + const result = await verifyProviderConnection(providerId, configuration, verificationFetch) + const checkedAt = new Date() + const [saved] = await db.insert(providerVerifications).values({ + workspaceId, + provider: providerId, + status: result.status, + configurationFingerprint: providerVerificationFingerprint(providerId, configuration), + checkedAt, + latencyMs: result.latencyMs, + errorCode: result.errorCode, + message: result.message, + details: result.details, + checkedById: request.user.sub, + }).onConflictDoUpdate({ + target: [providerVerifications.workspaceId, providerVerifications.provider], + set: { + status: result.status, + configurationFingerprint: providerVerificationFingerprint(providerId, configuration), + checkedAt, + latencyMs: result.latencyMs, + errorCode: result.errorCode, + message: result.message, + details: result.details, + checkedById: request.user.sub, + updatedAt: checkedAt, + }, + }).returning() + if (!saved) throw errors.serviceUnavailable('供应商验证结果未能保存') + + await audit({ + request, + workspaceId, + action: 'service.provider.verify', + resourceType: 'provider_verification', + resourceId: saved.id, + after: { + provider: providerId, + status: result.status, + checkedAt: checkedAt.toISOString(), + latencyMs: result.latencyMs, + errorCode: result.errorCode, + }, + }) + return readinessPayload(workspaceId, configuration) + }) +} diff --git a/server/src/routes/reviews.ts b/server/src/routes/reviews.ts new file mode 100644 index 0000000..3d4bb97 --- /dev/null +++ b/server/src/routes/reviews.ts @@ -0,0 +1,312 @@ +import type { FastifyInstance } from 'fastify' +import { and, desc, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { + assets, + assetVersions, + episodes, + memberships, + projects, + renders, + reviewComments, + reviewRequests, + scriptVersions, + shots, + shotVersions, +} from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { + notifyReviewAssigned, + notifyReviewComment, + notifyReviewRequested, + notifyReviewResolved, +} from '../lib/notifications.js' +import { requireWorkspacePermission } from '../lib/permissions.js' +import { publishRealtimeEvent } from '../lib/realtime.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const reviewParams = z.object({ reviewId: z.uuid() }) +const createReviewSchema = z.object({ + targetType: z.enum(['SCRIPT_VERSION', 'ASSET_VERSION', 'SHOT_VERSION', 'RENDER']), + targetId: z.uuid(), + assignedToId: z.uuid().nullable().optional(), + summary: z.string().trim().max(2000).default(''), +}) +const listReviewQuery = z.object({ + status: z.enum(['OPEN', 'APPROVED', 'CHANGES_REQUESTED', 'CANCELLED']).optional(), + assignedToId: z.uuid().optional(), +}) +const commentSchema = z.object({ + body: z.string().trim().min(1).max(10_000), + timecodeMs: z.number().int().min(0).max(86_400_000).nullable().optional(), + metadata: z.record(z.string(), z.unknown()).default({}), +}) +const decisionSchema = z.object({ + status: z.enum(['APPROVED', 'CHANGES_REQUESTED', 'CANCELLED']), + summary: z.string().trim().max(2000).optional(), +}) +const assignSchema = z.object({ + assignedToId: z.uuid().nullable(), + summary: z.string().trim().max(2000).optional(), +}) + +type ReviewTargetType = z.infer['targetType'] + +interface TargetContext { + workspaceId: string + projectId: string + episodeId?: string + parentId: string +} + +async function targetContext(type: ReviewTargetType, targetId: string): Promise { + if (type === 'SCRIPT_VERSION') { + const [row] = await db.select({ project: projects, episodeId: episodes.id }).from(scriptVersions) + .innerJoin(episodes, eq(episodes.id, scriptVersions.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(scriptVersions.id, targetId)).limit(1) + if (!row) throw errors.notFound('Script version') + return { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episodeId, parentId: row.episodeId } + } + if (type === 'ASSET_VERSION') { + const [row] = await db.select({ project: projects, assetId: assets.id }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .innerJoin(projects, eq(projects.id, assets.projectId)) + .where(eq(assetVersions.id, targetId)).limit(1) + if (!row) throw errors.notFound('Asset version') + return { workspaceId: row.project.workspaceId, projectId: row.project.id, parentId: row.assetId } + } + if (type === 'SHOT_VERSION') { + const [row] = await db.select({ project: projects, episodeId: episodes.id, shotId: shots.id }).from(shotVersions) + .innerJoin(shots, eq(shots.id, shotVersions.shotId)) + .innerJoin(episodes, eq(episodes.id, shots.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(shotVersions.id, targetId)).limit(1) + if (!row) throw errors.notFound('Shot version') + return { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episodeId, parentId: row.shotId } + } + const [row] = await db.select({ project: projects, episodeId: episodes.id, renderId: renders.id }).from(renders) + .innerJoin(episodes, eq(episodes.id, renders.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(renders.id, targetId)).limit(1) + if (!row) throw errors.notFound('Render') + return { workspaceId: row.project.workspaceId, projectId: row.project.id, episodeId: row.episodeId, parentId: row.renderId } +} + +async function reviewWithContext(reviewId: string) { + const [review] = await db.select().from(reviewRequests).where(eq(reviewRequests.id, reviewId)).limit(1) + if (!review) throw errors.notFound('Review request') + const target = await targetContext(review.targetType, review.targetId) + if (target.workspaceId !== review.workspaceId) throw new Error('Review target workspace mismatch') + return { review, target } +} + +async function updateTargetStatus(type: ReviewTargetType, target: TargetContext, decision: 'APPROVED' | 'CHANGES_REQUESTED') { + if (type === 'SCRIPT_VERSION' && target.episodeId) { + await db.update(episodes).set({ status: decision === 'APPROVED' ? 'STORYBOARDING' : 'SCRIPTING', updatedAt: new Date() }) + .where(eq(episodes.id, target.episodeId)) + } else if (type === 'ASSET_VERSION') { + await db.update(assets).set({ status: decision === 'APPROVED' ? 'APPROVED' : 'DRAFT', updatedAt: new Date() }) + .where(eq(assets.id, target.parentId)) + } else if (type === 'SHOT_VERSION') { + await db.update(shots).set({ status: decision === 'APPROVED' ? 'APPROVED' : 'DRAFT', updatedAt: new Date() }) + .where(eq(shots.id, target.parentId)) + } else if (type === 'RENDER') { + await db.update(renders).set({ status: decision === 'APPROVED' ? 'APPROVED' : 'REVIEW', updatedAt: new Date() }) + .where(eq(renders.id, target.parentId)) + } +} + +export async function reviewRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/workspaces/:workspaceId/reviews', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const query = parseWith(listReviewQuery, request.query) + await requireWorkspacePermission(request, workspaceId, 'project:read') + const filters = [eq(reviewRequests.workspaceId, workspaceId)] + if (query.status) filters.push(eq(reviewRequests.status, query.status)) + if (query.assignedToId) filters.push(eq(reviewRequests.assignedToId, query.assignedToId)) + return db.select().from(reviewRequests).where(and(...filters)).orderBy(desc(reviewRequests.updatedAt)).limit(200) + }) + + app.post('/workspaces/:workspaceId/reviews', async (request, reply) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(createReviewSchema, request.body) + await requireWorkspacePermission(request, workspaceId, 'content:write') + const target = await targetContext(input.targetType, input.targetId) + if (target.workspaceId !== workspaceId) throw errors.forbidden('Review target does not belong to this workspace') + if (input.assignedToId) { + const [assignee] = await db.select({ userId: memberships.userId, role: memberships.role }).from(memberships) + .where(and(eq(memberships.workspaceId, workspaceId), eq(memberships.userId, input.assignedToId))).limit(1) + if (!assignee || !['OWNER', 'ADMIN', 'REVIEWER'].includes(assignee.role)) throw errors.badRequest('Assigned reviewer must have review permission') + } + const [existing] = await db.select({ id: reviewRequests.id }).from(reviewRequests).where(and( + eq(reviewRequests.workspaceId, workspaceId), + eq(reviewRequests.targetType, input.targetType), + eq(reviewRequests.targetId, input.targetId), + eq(reviewRequests.status, 'OPEN'), + )).limit(1) + if (existing) throw errors.conflict('An open review already exists for this version') + + const [review] = await db.insert(reviewRequests).values({ + workspaceId, + targetType: input.targetType, + targetId: input.targetId, + requestedById: request.user.sub, + assignedToId: input.assignedToId, + summary: input.summary, + }).returning() + if (!review) throw new Error('Failed to create review request') + await audit({ request, workspaceId, action: 'review.create', resourceType: 'review_request', resourceId: review.id, after: review }) + await Promise.all([ + publishRealtimeEvent({ + workspaceId, + type: 'review.updated', + resourceType: 'review_request', + resourceId: review.id, + payload: { id: review.id, status: review.status }, + }), + notifyReviewRequested({ + workspaceId: review.workspaceId, + reviewId: review.id, + requestedById: review.requestedById, + assignedToId: review.assignedToId, + summary: review.summary, + targetType: review.targetType, + }).catch((error) => { + request.log.error({ err: error, reviewId: review.id }, 'Review notification failed') + return [] + }), + ]) + return reply.code(201).send(review) + }) + + app.get('/reviews/:reviewId', async (request) => { + const { reviewId } = parseWith(reviewParams, request.params) + const { review } = await reviewWithContext(reviewId) + await requireWorkspacePermission(request, review.workspaceId, 'project:read') + const comments = await db.select().from(reviewComments).where(eq(reviewComments.reviewRequestId, reviewId)).orderBy(reviewComments.createdAt) + return { ...review, comments } + }) + + app.post('/reviews/:reviewId/comments', async (request, reply) => { + const { reviewId } = parseWith(reviewParams, request.params) + const input = parseWith(commentSchema, request.body) + const { review } = await reviewWithContext(reviewId) + await requireWorkspacePermission(request, review.workspaceId, 'project:read') + if (review.status !== 'OPEN') throw errors.conflict('Comments can only be added to an open review') + const [comment] = await db.insert(reviewComments).values({ + reviewRequestId: reviewId, + authorId: request.user.sub, + body: input.body, + timecodeMs: input.timecodeMs, + metadata: input.metadata, + }).returning() + if (!comment) throw new Error('Failed to create review comment') + await audit({ request, workspaceId: review.workspaceId, action: 'review.comment.create', resourceType: 'review_comment', resourceId: comment.id, after: { reviewId, timecodeMs: comment.timecodeMs } }) + await Promise.all([ + publishRealtimeEvent({ + workspaceId: review.workspaceId, + type: 'review.updated', + resourceType: 'review_request', + resourceId: review.id, + payload: { id: review.id, status: review.status, commentId: comment.id }, + }), + notifyReviewComment({ + workspaceId: review.workspaceId, + reviewId: review.id, + authorId: request.user.sub, + requestedById: review.requestedById, + assignedToId: review.assignedToId, + body: comment.body, + commentId: comment.id, + }).catch((error) => { + request.log.error({ err: error, reviewId: review.id }, 'Review comment notification failed') + return [] + }), + ]) + return reply.code(201).send(comment) + }) + + app.patch('/reviews/:reviewId', async (request) => { + const { reviewId } = parseWith(reviewParams, request.params) + const input = parseWith(assignSchema, request.body) + const { review } = await reviewWithContext(reviewId) + await requireWorkspacePermission(request, review.workspaceId, 'review:decide') + if (review.status !== 'OPEN') throw errors.conflict('Only open reviews can be reassigned') + if (input.assignedToId) { + const [assignee] = await db.select({ userId: memberships.userId, role: memberships.role }).from(memberships) + .where(and(eq(memberships.workspaceId, review.workspaceId), eq(memberships.userId, input.assignedToId))).limit(1) + if (!assignee || !['OWNER', 'ADMIN', 'REVIEWER'].includes(assignee.role)) throw errors.badRequest('Assigned reviewer must have review permission') + } + const [updated] = await db.update(reviewRequests).set({ + assignedToId: input.assignedToId, + ...(input.summary !== undefined ? { summary: input.summary } : {}), + updatedAt: new Date(), + }).where(and(eq(reviewRequests.id, reviewId), eq(reviewRequests.status, 'OPEN'))).returning() + if (!updated) throw errors.conflict('Review was resolved concurrently') + await audit({ request, workspaceId: review.workspaceId, action: 'review.assign', resourceType: 'review_request', resourceId: reviewId, before: { assignedToId: review.assignedToId }, after: { assignedToId: updated.assignedToId } }) + await publishRealtimeEvent({ + workspaceId: review.workspaceId, + type: 'review.updated', + resourceType: 'review_request', + resourceId: review.id, + payload: { id: review.id, status: review.status, assignedToId: updated.assignedToId }, + }) + if (updated.assignedToId) { + await notifyReviewAssigned({ + workspaceId: review.workspaceId, + reviewId: review.id, + assignedToId: updated.assignedToId, + assignedById: request.user.sub, + summary: updated.summary, + }).catch((error) => request.log.error({ err: error, reviewId: review.id }, 'Review assignment notification failed')) + } + return updated + }) + + app.post('/reviews/:reviewId/decision', async (request) => { + const { reviewId } = parseWith(reviewParams, request.params) + const input = parseWith(decisionSchema, request.body) + const { review, target } = await reviewWithContext(reviewId) + await requireWorkspacePermission(request, review.workspaceId, 'review:decide') + if (review.status !== 'OPEN') throw errors.conflict(`Review has already been resolved as ${review.status}`) + const now = new Date() + const [updated] = await db.update(reviewRequests).set({ + status: input.status, + summary: input.summary ?? review.summary, + resolvedById: request.user.sub, + resolvedAt: now, + updatedAt: now, + }).where(and(eq(reviewRequests.id, reviewId), eq(reviewRequests.status, 'OPEN'))).returning() + if (!updated) throw errors.conflict('Review was resolved concurrently') + if (input.status !== 'CANCELLED') await updateTargetStatus(review.targetType, target, input.status) + await audit({ request, workspaceId: review.workspaceId, action: 'review.decide', resourceType: 'review_request', resourceId: reviewId, before: { status: review.status }, after: { status: input.status } }) + await Promise.all([ + publishRealtimeEvent({ + workspaceId: review.workspaceId, + type: 'review.updated', + resourceType: 'review_request', + resourceId: review.id, + payload: { id: review.id, status: input.status }, + }), + notifyReviewResolved({ + workspaceId: review.workspaceId, + reviewId: review.id, + requestedById: review.requestedById, + resolvedById: request.user.sub, + status: input.status, + summary: updated.summary, + }).catch((error) => { + request.log.error({ err: error, reviewId: review.id }, 'Review resolution notification failed') + return null + }), + ]) + return updated + }) +} diff --git a/server/src/routes/shots.ts b/server/src/routes/shots.ts new file mode 100644 index 0000000..95caa12 --- /dev/null +++ b/server/src/routes/shots.ts @@ -0,0 +1,308 @@ +import type { FastifyInstance } from 'fastify' +import { and, asc, desc, eq, sql } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { assets, assetVersions, episodes, projects, shots, shotVersions } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission } from '../lib/permissions.js' +import { shotProductionSettingsSchema, shotProductionSettingsUpdateSchema } from '../media/ffmpeg.js' +import { presignDownload } from '../storage/client.js' + +const episodeParams = z.object({ episodeId: z.uuid() }) +const shotParams = z.object({ shotId: z.uuid() }) +const shotVersionParams = z.object({ shotId: z.uuid(), versionId: z.uuid() }) +const createShotSchema = z.object({ + shotNumber: z.number().int().positive(), + sceneNumber: z.number().int().positive(), + title: z.string().trim().min(1).max(180), + description: z.string().max(10000).default(''), + shotType: z.string().max(50).default('中景'), + cameraMotion: z.string().max(50).default('静止'), + durationMs: z.number().int().min(100).max(600000).default(3000), + characterAssetIds: z.array(z.uuid()).max(20).default([]), + sceneAssetId: z.uuid().nullable().optional(), + sortOrder: z.number().int(), +}) +const updateShotSchema = createShotSchema.partial().extend({ + status: z.enum(['DRAFT', 'GENERATING', 'REVIEW', 'APPROVED', 'RENDERED']).optional(), +}) +const reorderSchema = z.object({ + shots: z.array(z.object({ id: z.uuid(), sortOrder: z.number().int() })).min(1).max(500), +}) +const versionSchema = z.object({ + imageAssetVersionId: z.uuid().nullable().optional(), + videoStorageKey: z.string().max(1000).nullable().optional(), + audioStorageKey: z.string().max(1000).nullable().optional(), + subtitleText: z.string().max(10000).optional(), + production: shotProductionSettingsUpdateSchema.optional(), + generationParams: z.record(z.string(), z.unknown()).optional(), +}) +const productionEditSchema = z.object({ + title: z.string().trim().min(1).max(180).optional(), + cameraMotion: z.string().max(50).optional(), + durationMs: z.number().int().min(100).max(600000), + subtitleText: z.string().max(10000).optional(), + production: shotProductionSettingsUpdateSchema.default({}), + generationParams: z.record(z.string(), z.unknown()).default({}), +}) + +async function episodeProject(episodeId: string) { + const [row] = await db.select({ episode: episodes, project: projects }).from(episodes).innerJoin(projects, eq(projects.id, episodes.projectId)).where(eq(episodes.id, episodeId)).limit(1) + if (!row) throw errors.notFound('Episode') + return row +} + +async function shotProject(shotId: string) { + const [row] = await db.select({ shot: shots, episode: episodes, project: projects }).from(shots) + .innerJoin(episodes, eq(episodes.id, shots.episodeId)) + .innerJoin(projects, eq(projects.id, episodes.projectId)) + .where(eq(shots.id, shotId)).limit(1) + if (!row) throw errors.notFound('Shot') + return row +} + +async function mediaForVersion(version: typeof shotVersions.$inferSelect, projectId: string) { + let imageStorageKey: string | null = null + if (version.imageAssetVersionId) { + const [image] = await db.select({ storageKey: assetVersions.storageKey }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .where(and(eq(assetVersions.id, version.imageAssetVersionId), eq(assets.projectId, projectId))).limit(1) + imageStorageKey = image?.storageKey ?? null + } + const [imageUrl, videoUrl, audioUrl] = await Promise.all([ + imageStorageKey ? presignDownload(imageStorageKey) : Promise.resolve(null), + version.videoStorageKey ? presignDownload(version.videoStorageKey) : Promise.resolve(null), + version.audioStorageKey ? presignDownload(version.audioStorageKey) : Promise.resolve(null), + ]) + return { versionId: version.id, imageUrl, videoUrl, audioUrl, subtitleText: version.subtitleText } +} + +export async function shotRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.get('/episodes/:episodeId/shots', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'project:read') + return db.select({ shot: shots, currentVersion: shotVersions }).from(shots).leftJoin(shotVersions, and( + eq(shotVersions.shotId, shots.id), + eq(shotVersions.isCurrent, true), + )).where(eq(shots.episodeId, episodeId)).orderBy(asc(shots.sortOrder)) + }) + + app.post('/episodes/:episodeId/shots', async (request, reply) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(createShotSchema, request.body) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'content:write') + const [shot] = await db.insert(shots).values({ episodeId, createdById: request.user.sub, ...input }).returning() + if (!shot) throw new Error('Failed to create shot') + await audit({ request, workspaceId: row.project.workspaceId, action: 'shot.create', resourceType: 'shot', resourceId: shot.id, after: shot }) + return reply.code(201).send(shot) + }) + + app.patch('/episodes/:episodeId/shots/reorder', async (request) => { + const { episodeId } = parseWith(episodeParams, request.params) + const input = parseWith(reorderSchema, request.body) + const row = await episodeProject(episodeId) + await requireProjectPermission(request, row.project.id, 'content:write') + await db.transaction(async (tx) => { + for (const item of input.shots) { + await tx.update(shots).set({ sortOrder: item.sortOrder, updatedAt: new Date() }).where(and(eq(shots.id, item.id), eq(shots.episodeId, episodeId))) + } + }) + await audit({ request, workspaceId: row.project.workspaceId, action: 'shots.reorder', resourceType: 'episode', resourceId: episodeId, after: { shots: input.shots } }) + return { updated: input.shots.length } + }) + + app.get('/shots/:shotId', async (request) => { + const { shotId } = parseWith(shotParams, request.params) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'project:read') + const versions = await db.select().from(shotVersions).where(eq(shotVersions.shotId, shotId)).orderBy(desc(shotVersions.version)) + return { ...row.shot, versions } + }) + + app.get('/shots/:shotId/media', async (request) => { + const { shotId } = parseWith(shotParams, request.params) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'project:read') + const [version] = await db.select().from(shotVersions).where(and( + eq(shotVersions.shotId, shotId), + eq(shotVersions.isCurrent, true), + )).limit(1) + if (!version) return { versionId: null, imageUrl: null, videoUrl: null, audioUrl: null, subtitleText: '' } + + return mediaForVersion(version, row.project.id) + }) + + app.get('/shots/:shotId/versions/:versionId/media', async (request) => { + const { shotId, versionId } = parseWith(shotVersionParams, request.params) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'project:read') + const [version] = await db.select().from(shotVersions).where(and( + eq(shotVersions.id, versionId), + eq(shotVersions.shotId, shotId), + )).limit(1) + if (!version) throw errors.notFound('Shot version') + return mediaForVersion(version, row.project.id) + }) + + app.patch('/shots/:shotId', async (request) => { + const { shotId } = parseWith(shotParams, request.params) + const input = parseWith(updateShotSchema, request.body) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'content:write') + const [updated] = await db.update(shots).set({ ...input, updatedAt: new Date() }).where(eq(shots.id, shotId)).returning() + if (!updated) throw errors.notFound('Shot') + await audit({ request, workspaceId: row.project.workspaceId, action: 'shot.update', resourceType: 'shot', resourceId: shotId, before: row.shot, after: updated }) + return updated + }) + + app.patch('/shots/:shotId/production', async (request) => { + const { shotId } = parseWith(shotParams, request.params) + const input = parseWith(productionEditSchema, request.body) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'content:write') + + const result = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${shotId}`}))`) + const [latest] = await tx.select().from(shotVersions).where(eq(shotVersions.shotId, shotId)).orderBy(desc(shotVersions.version)).limit(1) + const [current] = await tx.select().from(shotVersions).where(and( + eq(shotVersions.shotId, shotId), + eq(shotVersions.isCurrent, true), + )).limit(1) + const base = current ?? latest + const currentProduction = shotProductionSettingsSchema.parse(base?.generationParams?.production ?? {}) + const production = shotProductionSettingsSchema.parse({ ...currentProduction, ...input.production }) + + const [updatedShot] = await tx.update(shots).set({ + ...(input.title ? { title: input.title } : {}), + ...(input.cameraMotion !== undefined ? { cameraMotion: input.cameraMotion } : {}), + durationMs: input.durationMs, + updatedAt: new Date(), + }).where(eq(shots.id, shotId)).returning() + if (!updatedShot) throw errors.notFound('Shot') + + await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, shotId)) + const [version] = await tx.insert(shotVersions).values({ + shotId, + version: (latest?.version ?? 0) + 1, + imageAssetVersionId: base?.imageAssetVersionId ?? null, + videoStorageKey: base?.videoStorageKey ?? null, + audioStorageKey: base?.audioStorageKey ?? null, + subtitleText: input.subtitleText ?? base?.subtitleText ?? '', + generationParams: { ...(base?.generationParams ?? {}), ...input.generationParams, production, source: 'manual' }, + isCurrent: true, + createdById: request.user.sub, + }).returning() + if (!version) throw new Error('Failed to create shot production version') + return { shot: updatedShot, version } + }) + + await audit({ + request, + workspaceId: row.project.workspaceId, + action: 'shot.production.update', + resourceType: 'shot', + resourceId: shotId, + before: row.shot, + after: { shot: result.shot, version: result.version.version, production: result.version.generationParams.production }, + }) + return result + }) + + app.delete('/shots/:shotId', async (request, reply) => { + const { shotId } = parseWith(shotParams, request.params) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'content:write') + await db.transaction(async (tx) => { + await tx.update(assets).set({ status: 'ARCHIVED', updatedAt: new Date() }).where(and( + eq(assets.projectId, row.project.id), + eq(assets.type, 'SHOT'), + sql`${assets.metadata}->>'shotId' = ${shotId}`, + )) + await tx.delete(shots).where(eq(shots.id, shotId)) + }) + await audit({ request, workspaceId: row.project.workspaceId, action: 'shot.delete', resourceType: 'shot', resourceId: shotId, before: row.shot }) + return reply.code(204).send() + }) + + app.post('/shots/:shotId/versions', async (request, reply) => { + const { shotId } = parseWith(shotParams, request.params) + const input = parseWith(versionSchema, request.body) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'content:write') + const projectPrefix = `${row.project.workspaceId}/${row.project.id}/` + for (const storageKey of [input.videoStorageKey, input.audioStorageKey]) { + if (storageKey && !storageKey.startsWith(projectPrefix)) throw errors.forbidden('Shot media must belong to the same project') + } + if (input.imageAssetVersionId) { + const [image] = await db.select({ id: assetVersions.id }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .where(and(eq(assetVersions.id, input.imageAssetVersionId), eq(assets.projectId, row.project.id))).limit(1) + if (!image) throw errors.badRequest('Image asset version must belong to the same project') + } + + const created = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${shotId}`}))`) + const [latest] = await tx.select().from(shotVersions).where(eq(shotVersions.shotId, shotId)).orderBy(desc(shotVersions.version)).limit(1) + const [current] = await tx.select().from(shotVersions).where(and( + eq(shotVersions.shotId, shotId), + eq(shotVersions.isCurrent, true), + )).limit(1) + const base = current ?? latest + const latestProduction = shotProductionSettingsSchema.parse(base?.generationParams?.production ?? {}) + const production = input.production + ? shotProductionSettingsSchema.parse({ ...latestProduction, ...input.production }) + : latestProduction + await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, shotId)) + const [version] = await tx.insert(shotVersions).values({ + shotId, + version: (latest?.version ?? 0) + 1, + imageAssetVersionId: input.imageAssetVersionId !== undefined ? input.imageAssetVersionId : base?.imageAssetVersionId ?? null, + videoStorageKey: input.videoStorageKey !== undefined ? input.videoStorageKey : base?.videoStorageKey ?? null, + audioStorageKey: input.audioStorageKey !== undefined ? input.audioStorageKey : base?.audioStorageKey ?? null, + subtitleText: input.subtitleText ?? base?.subtitleText ?? '', + generationParams: { ...(base?.generationParams ?? {}), ...(input.generationParams ?? {}), production, source: 'manual' }, + isCurrent: true, + createdById: request.user.sub, + }).returning() + return version + }) + if (!created) throw new Error('Failed to create shot version') + await audit({ request, workspaceId: row.project.workspaceId, action: 'shot.version.create', resourceType: 'shot_version', resourceId: created.id, after: { shotId, version: created.version } }) + return reply.code(201).send(created) + }) + + app.post('/shots/:shotId/versions/:versionId/activate', async (request) => { + const { shotId, versionId } = parseWith(shotVersionParams, request.params) + const row = await shotProject(shotId) + await requireProjectPermission(request, row.project.id, 'content:write') + + const result = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`shot:${shotId}`}))`) + const [version] = await tx.select().from(shotVersions).where(and( + eq(shotVersions.id, versionId), + eq(shotVersions.shotId, shotId), + )).limit(1) + if (!version) throw errors.notFound('Shot version') + if (version.isCurrent) return { version, changed: false } + await tx.update(shotVersions).set({ isCurrent: false }).where(eq(shotVersions.shotId, shotId)) + const [activated] = await tx.update(shotVersions).set({ isCurrent: true }).where(and( + eq(shotVersions.id, versionId), + eq(shotVersions.shotId, shotId), + )).returning() + if (!activated) throw errors.notFound('Shot version') + return { version: activated, changed: true } + }) + + if (result.changed) { + await audit({ request, workspaceId: row.project.workspaceId, action: 'shot.version.activate', resourceType: 'shot_version', resourceId: versionId, after: { shotId, version: result.version.version } }) + } + return result.version + }) +} diff --git a/server/src/routes/uploads.ts b/server/src/routes/uploads.ts new file mode 100644 index 0000000..bddd010 --- /dev/null +++ b/server/src/routes/uploads.ts @@ -0,0 +1,68 @@ +import type { FastifyInstance } from 'fastify' +import { and, eq } from 'drizzle-orm' +import { z } from 'zod' +import { config } from '../config.js' +import { db } from '../db/client.js' +import { assets, assetVersions, projects } from '../db/schema.js' +import { requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { requireProjectPermission } from '../lib/permissions.js' +import { presignDownload, presignUpload, projectObjectKey, putObject } from '../storage/client.js' + +const projectParams = z.object({ projectId: z.uuid() }) +const versionParams = z.object({ assetId: z.uuid(), versionId: z.uuid() }) +const presignSchema = z.object({ + filename: z.string().trim().min(1).max(255), + contentType: z.string().trim().min(3).max(120), +}) + +export async function uploadRoutes(app: FastifyInstance) { + app.addHook('preHandler', requireAuth) + + app.post('/projects/:projectId/uploads/presign', async (request) => { + const { projectId } = parseWith(projectParams, request.params) + const input = parseWith(presignSchema, request.body) + const { project } = await requireProjectPermission(request, projectId, 'content:write') + const key = projectObjectKey(project.workspaceId, projectId, input.filename) + const uploadUrl = await presignUpload(key, input.contentType) + await audit({ request, workspaceId: project.workspaceId, action: 'upload.presign', resourceType: 'project', resourceId: projectId, after: { key, contentType: input.contentType } }) + return { key, uploadUrl, expiresIn: 900, maxBytes: config.MAX_UPLOAD_BYTES } + }) + + app.post('/projects/:projectId/uploads', async (request, reply) => { + const { projectId } = parseWith(projectParams, request.params) + const { project } = await requireProjectPermission(request, projectId, 'content:write') + const file = await request.file() + if (!file) throw errors.badRequest('A multipart file is required') + const buffer = await file.toBuffer() + if (buffer.length > config.MAX_UPLOAD_BYTES) throw errors.badRequest('File exceeds upload limit') + const key = projectObjectKey(project.workspaceId, projectId, file.filename) + await putObject({ + key, + body: buffer, + contentType: file.mimetype, + metadata: { projectId, uploadedBy: request.user.sub }, + }) + await audit({ request, workspaceId: project.workspaceId, action: 'upload.create', resourceType: 'object', resourceId: projectId, after: { key, byteSize: buffer.length, contentType: file.mimetype } }) + return reply.code(201).send({ key, byteSize: buffer.length, contentType: file.mimetype, filename: file.filename }) + }) + + app.get('/assets/:assetId/versions/:versionId/url', async (request) => { + const { assetId, versionId } = parseWith(versionParams, request.params) + const [row] = await db.select({ + storageKey: assetVersions.storageKey, + projectId: assets.projectId, + workspaceId: projects.workspaceId, + }).from(assetVersions) + .innerJoin(assets, eq(assets.id, assetVersions.assetId)) + .innerJoin(projects, eq(projects.id, assets.projectId)) + .where(and(eq(assetVersions.id, versionId), eq(assetVersions.assetId, assetId))) + .limit(1) + if (!row) throw errors.notFound('Asset version') + await requireProjectPermission(request, row.projectId, 'project:read') + if (!row.storageKey) throw errors.notFound('Asset media') + return { url: await presignDownload(row.storageKey), expiresIn: 900 } + }) +} diff --git a/server/src/routes/version-review.integration.test.ts b/server/src/routes/version-review.integration.test.ts new file mode 100644 index 0000000..a61616e --- /dev/null +++ b/server/src/routes/version-review.integration.test.ts @@ -0,0 +1,219 @@ +import type { FastifyInstance } from 'fastify' +import { eq } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { users, workspaces } from '../db/schema.js' + +describe.sequential('version activation and review workflow', () => { + let app: FastifyInstance + let token = '' + let userId = '' + let workspaceId = '' + let projectId = '' + let episodeId = '' + let assetId = '' + + async function request(method: string, url: string, payload?: unknown) { + const response = await app.inject({ + method, + url, + headers: token ? { authorization: `Bearer ${token}` } : undefined, + payload, + }) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.json() + } + + beforeAll(async () => { + app = await buildApp() + const nonce = crypto.randomUUID().slice(0, 8) + const session = await request('POST', '/api/v1/auth/register', { + email: `version-review-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: '集成测试用户', + workspaceName: `版本审核测试 ${nonce}`, + }) + token = session.accessToken + userId = session.user.id + workspaceId = session.workspaces[0].id + const project = await request('POST', `/api/v1/workspaces/${workspaceId}/projects`, { + name: '版本审核测试项目', + logline: '验证不可变版本和审核状态流转。', + genre: '测试', + visualBible: {}, + }) + projectId = project.id + const episode = await request('POST', `/api/v1/projects/${projectId}/episodes`, { + episodeNumber: 1, + title: '测试分集', + synopsis: '', + targetDurationSeconds: 60, + }) + episodeId = episode.id + }) + + afterAll(async () => { + if (workspaceId) await db.delete(workspaces).where(eq(workspaces.id, workspaceId)) + if (userId) await db.delete(users).where(eq(users.id, userId)) + await app.close() + }) + + it('activates previous script, asset, and shot versions', async () => { + const scriptOne = await request('POST', `/api/v1/episodes/${episodeId}/scripts`, { + title: '测试分集', + content: { source: 'test' }, + plainText: '第一版剧本', + changeSummary: 'v1', + }) + await request('POST', `/api/v1/episodes/${episodeId}/scripts`, { + title: '测试分集', + content: { source: 'test' }, + plainText: '第二版剧本', + changeSummary: 'v2', + }) + await request('POST', `/api/v1/episodes/${episodeId}/scripts/${scriptOne.id}/activate`) + const scripts = await request('GET', `/api/v1/episodes/${episodeId}/scripts`) + expect(scripts.find((version: { id: string }) => version.id === scriptOne.id)?.isCurrent).toBe(true) + expect(scripts.filter((version: { isCurrent: boolean }) => version.isCurrent)).toHaveLength(1) + + const asset = await request('POST', `/api/v1/projects/${projectId}/assets`, { + type: 'CHARACTER', + name: '一致性测试角色', + description: '', + tags: [], + metadata: {}, + }) + assetId = asset.id + const assetOne = await request('POST', `/api/v1/assets/${asset.id}/versions`, { prompt: '角色第一版', generationParams: { source: 'test' } }) + await request('POST', `/api/v1/assets/${asset.id}/versions`, { prompt: '角色第二版', generationParams: { source: 'test' } }) + await request('POST', `/api/v1/assets/${asset.id}/versions/${assetOne.id}/activate`) + const assetDetail = await request('GET', `/api/v1/assets/${asset.id}`) + expect(assetDetail.versions.find((version: { id: string }) => version.id === assetOne.id)?.isCurrent).toBe(true) + expect(assetDetail.versions.filter((version: { isCurrent: boolean }) => version.isCurrent)).toHaveLength(1) + + const shot = await request('POST', `/api/v1/episodes/${episodeId}/shots`, { + shotNumber: 1, + sceneNumber: 1, + title: '测试镜头', + description: '', + shotType: '中景', + cameraMotion: '静止', + durationMs: 3000, + characterAssetIds: [], + sceneAssetId: null, + sortOrder: 100, + }) + const shotOne = await request('POST', `/api/v1/shots/${shot.id}/versions`, { subtitleText: '镜头第一版', generationParams: { source: 'test' } }) + await request('POST', `/api/v1/shots/${shot.id}/versions`, { subtitleText: '镜头第二版', videoStorageKey: `${workspaceId}/${projectId}/shots/video/newer.mp4`, generationParams: { source: 'test' } }) + await request('POST', `/api/v1/shots/${shot.id}/versions/${shotOne.id}/activate`) + const editedShot = await request('POST', `/api/v1/shots/${shot.id}/versions`, { + production: { + trimStartMs: 450, + playbackRate: 1.25, + audioVolume: 0.7, + fit: 'cover', + transition: 'fade', + transitionDurationMs: 300, + }, + generationParams: { operation: 'production-edit' }, + }) + expect(editedShot.version).toBe(3) + expect(editedShot.subtitleText).toBe('镜头第一版') + expect(editedShot.videoStorageKey).toBeNull() + expect(editedShot.generationParams.production).toEqual({ + trimStartMs: 450, + playbackRate: 1.25, + audioVolume: 0.7, + fit: 'cover', + transition: 'fade', + transitionDurationMs: 300, + }) + const shotDetail = await request('GET', `/api/v1/shots/${shot.id}`) + expect(shotDetail.versions.find((version: { id: string }) => version.id === editedShot.id)?.isCurrent).toBe(true) + expect(shotDetail.versions.filter((version: { isCurrent: boolean }) => version.isCurrent)).toHaveLength(1) + + const trimmed = await request('PATCH', `/api/v1/shots/${shot.id}/production`, { + durationMs: 1850, + title: '裁剪后的测试镜头', + subtitleText: '裁剪后的字幕', + production: { trimStartMs: 650 }, + generationParams: { operation: 'timeline-trim' }, + }) + expect(trimmed.shot.durationMs).toBe(1850) + expect(trimmed.shot.title).toBe('裁剪后的测试镜头') + expect(trimmed.version.version).toBe(4) + expect(trimmed.version.subtitleText).toBe('裁剪后的字幕') + expect(trimmed.version.generationParams.operation).toBe('timeline-trim') + expect(trimmed.version.generationParams.production).toEqual({ + trimStartMs: 650, + playbackRate: 1.25, + audioVolume: 0.7, + fit: 'cover', + transition: 'fade', + transitionDurationMs: 300, + }) + + const invalidProduction = await app.inject({ + method: 'POST', + url: `/api/v1/shots/${shot.id}/versions`, + headers: { authorization: `Bearer ${token}` }, + payload: { production: { audioVolume: 2.1 } }, + }) + expect(invalidProduction.statusCode).toBe(400) + }) + + it('assigns, comments on, and approves a version review', async () => { + const scripts = await request('GET', `/api/v1/episodes/${episodeId}/scripts`) + const current = scripts.find((version: { isCurrent: boolean }) => version.isCurrent) + const review = await request('POST', `/api/v1/workspaces/${workspaceId}/reviews`, { + targetType: 'SCRIPT_VERSION', + targetId: current.id, + summary: '测试剧本审核', + }) + const assigned = await request('PATCH', `/api/v1/reviews/${review.id}`, { assignedToId: userId }) + expect(assigned.assignedToId).toBe(userId) + + const comment = await request('POST', `/api/v1/reviews/${review.id}/comments`, { + body: '第 2 秒需要调整对白。', + timecodeMs: 2000, + metadata: {}, + }) + expect(comment.timecodeMs).toBe(2000) + + const decision = await request('POST', `/api/v1/reviews/${review.id}/decision`, { + status: 'APPROVED', + summary: '测试通过', + }) + expect(decision.status).toBe('APPROVED') + const detail = await request('GET', `/api/v1/reviews/${review.id}`) + expect(detail.comments).toHaveLength(1) + expect(detail.status).toBe('APPROVED') + }) + + it('reports usage and prevents jobs from exceeding the workspace limit', async () => { + const summary = await request('GET', `/api/v1/workspaces/${workspaceId}/billing/summary`) + expect(summary.monthlyCreditLimit).toBe(10000) + expect(summary.usedCredits).toBe(0) + expect(summary.reservedCredits).toBe(0) + + await db.update(workspaces).set({ monthlyCreditLimit: 1 }).where(eq(workspaces.id, workspaceId)) + try { + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${projectId}/jobs`, + headers: { authorization: `Bearer ${token}` }, + payload: { + type: 'ASSET_GENERATE', + input: { assetId, prompt: '额度拦截测试' }, + }, + }) + expect(response.statusCode).toBe(402) + const body = response.json() + expect(body.error.code, response.body).toBe('CREDIT_LIMIT_EXCEEDED') + expect(body.error.details.estimatedCredits).toBe(120) + } finally { + await db.update(workspaces).set({ monthlyCreditLimit: 10000 }).where(eq(workspaces.id, workspaceId)) + } + }) +}) diff --git a/server/src/routes/workspace-invitations.integration.test.ts b/server/src/routes/workspace-invitations.integration.test.ts new file mode 100644 index 0000000..a8337b5 --- /dev/null +++ b/server/src/routes/workspace-invitations.integration.test.ts @@ -0,0 +1,227 @@ +import { createHash } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { auditLogs, users, workspaceInvitations, workspaces } from '../db/schema.js' +import type { WorkspaceInvitationEmailInput } from '../lib/mailer.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +interface CreatedInvitation { + invitation: { + id: string + email: string + role: string + status: string + expiresAt: string + } + token: string + emailDelivery: { + status: 'SENT' | 'NOT_CONFIGURED' | 'FAILED' + provider: 'resend' | 'development' | null + } +} + +describe.sequential('workspace invitations', () => { + let app: FastifyInstance + let owner: Session + let invitee: Session + let outsider: Session + let editor: Session + let workspaceId = '' + let acceptedInvitation: CreatedInvitation + let failInvitationDelivery = false + const invitationEmails: WorkspaceInvitationEmailInput[] = [] + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string | null, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: token ? { authorization: `Bearer ${token}` } : undefined, + payload, + }) + } + + async function request>(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.statusCode === 204 ? null : response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + async function createInvitation(email: string, role = 'EDITOR') { + return request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/invitations`, { email, role }) as Promise + } + + beforeAll(async () => { + app = await buildApp({ + invitationMailer: { + async send(input) { + invitationEmails.push(input) + if (failInvitationDelivery) throw new Error('mailbox unavailable') + return { sent: true, provider: 'resend', id: `email-${invitationEmails.length}` } + }, + }, + }) + owner = await registerAccount('invitation-owner') + invitee = await registerAccount('invitation-target') + outsider = await registerAccount('invitation-outsider') + editor = await registerAccount('invitation-editor') + workspaceId = owner.workspaces[0].id + await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: editor.user.email, + role: 'EDITOR', + }) + }) + + afterAll(async () => { + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('lets administrators create and list a hashed invitation', async () => { + acceptedInvitation = await createInvitation(invitee.user.email.toUpperCase(), 'REVIEWER') + expect(acceptedInvitation.token).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(acceptedInvitation.invitation).toMatchObject({ + email: invitee.user.email, + role: 'REVIEWER', + status: 'PENDING', + }) + expect(acceptedInvitation.emailDelivery).toEqual({ status: 'SENT', provider: 'resend' }) + expect(acceptedInvitation.invitation).not.toHaveProperty('tokenHash') + + expect(invitationEmails.at(-1)).toMatchObject({ + to: invitee.user.email, + inviterName: 'invitation-owner account', + roleLabel: '审核员', + }) + expect(invitationEmails.at(-1)?.workspaceName).toContain('invitation-owner workspace') + const invitationEmailUrl = new URL(invitationEmails.at(-1)!.url) + expect(invitationEmailUrl.searchParams.get('invite')).toBe(acceptedInvitation.token) + + const [stored] = await db.select().from(workspaceInvitations) + .where(eq(workspaceInvitations.id, acceptedInvitation.invitation.id)).limit(1) + expect(stored?.tokenHash).toBe(createHash('sha256').update(acceptedInvitation.token).digest('hex')) + expect(stored?.tokenHash).not.toContain(acceptedInvitation.token) + + const listed = await request>>(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/invitations`) + expect(listed).toEqual(expect.arrayContaining([expect.objectContaining({ id: acceptedInvitation.invitation.id, status: 'PENDING' })])) + expect(JSON.stringify(listed)).not.toContain(acceptedInvitation.token) + expect(JSON.stringify(listed)).not.toContain('tokenHash') + }) + + it('preserves the invitation when email delivery fails', async () => { + failInvitationDelivery = true + let created: CreatedInvitation + try { + created = await createInvitation(outsider.user.email, 'VIEWER') + } finally { + failInvitationDelivery = false + } + + expect(created!.emailDelivery).toEqual({ status: 'FAILED', provider: null }) + const [stored] = await db.select({ id: workspaceInvitations.id, revokedAt: workspaceInvitations.revokedAt }) + .from(workspaceInvitations).where(eq(workspaceInvitations.id, created!.invitation.id)).limit(1) + expect(stored).toEqual({ id: created!.invitation.id, revokedAt: null }) + }) + + it('rejects unauthenticated and non-admin invitation management', async () => { + const unauthenticated = await inject(null, 'GET', `/api/v1/workspaces/${workspaceId}/invitations`) + expect(unauthenticated.statusCode).toBe(401) + + const forbiddenList = await inject(editor.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/invitations`) + expect(forbiddenList.statusCode, forbiddenList.body).toBe(403) + const forbiddenCreate = await inject(editor.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/invitations`, { + email: outsider.user.email, + role: 'VIEWER', + }) + expect(forbiddenCreate.statusCode, forbiddenCreate.body).toBe(403) + }) + + it('requires the authenticated account email and accepts exactly once', async () => { + const wrongAccount = await inject(outsider.accessToken, 'POST', '/api/v1/workspaces/invitations/accept', { + token: acceptedInvitation.token, + }) + expect(wrongAccount.statusCode, wrongAccount.body).toBe(403) + + const accepted = await request<{ workspace: { id: string; role: string }; invitation: { status: string } }>( + invitee.accessToken, + 'POST', + '/api/v1/workspaces/invitations/accept', + { token: acceptedInvitation.token }, + ) + expect(accepted.workspace).toMatchObject({ id: workspaceId, role: 'REVIEWER' }) + expect(accepted.invitation.status).toBe('ACCEPTED') + + const available = await request>(invitee.accessToken, 'GET', '/api/v1/workspaces') + expect(available).toEqual(expect.arrayContaining([expect.objectContaining({ id: workspaceId, role: 'REVIEWER' })])) + + const duplicate = await inject(invitee.accessToken, 'POST', '/api/v1/workspaces/invitations/accept', { + token: acceptedInvitation.token, + }) + expect(duplicate.statusCode, duplicate.body).toBe(409) + }) + + it('revokes invitations and prevents later acceptance', async () => { + const created = await createInvitation(outsider.user.email, 'VIEWER') + const revoked = await inject(owner.accessToken, 'DELETE', `/api/v1/workspaces/${workspaceId}/invitations/${created.invitation.id}`) + expect(revoked.statusCode, revoked.body).toBe(204) + + const rejected = await inject(outsider.accessToken, 'POST', '/api/v1/workspaces/invitations/accept', { token: created.token }) + expect(rejected.statusCode, rejected.body).toBe(400) + expect(rejected.json().error.message).toContain('revoked') + + const listed = await request>(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/invitations`) + expect(listed.some((invitation) => invitation.id === created.invitation.id)).toBe(false) + }) + + it('rejects expired invitations and records lifecycle audit events', async () => { + const created = await createInvitation(outsider.user.email, 'EDITOR') + await db.update(workspaceInvitations).set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(workspaceInvitations.id, created.invitation.id)) + + const expired = await inject(outsider.accessToken, 'POST', '/api/v1/workspaces/invitations/accept', { token: created.token }) + expect(expired.statusCode, expired.body).toBe(400) + expect(expired.json().error.message).toContain('expired') + + const listed = await request>(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/invitations`) + expect(listed).toEqual(expect.arrayContaining([expect.objectContaining({ id: created.invitation.id, status: 'EXPIRED' })])) + + const actions = await db.select({ action: auditLogs.action }).from(auditLogs).where(and( + eq(auditLogs.workspaceId, workspaceId), + inArray(auditLogs.action, ['member.invitation.create', 'member.invitation.revoke', 'member.invitation.accept']), + )) + expect(actions.map((row) => row.action)).toEqual(expect.arrayContaining([ + 'member.invitation.create', + 'member.invitation.revoke', + 'member.invitation.accept', + ])) + }) +}) diff --git a/server/src/routes/workspace-members.integration.test.ts b/server/src/routes/workspace-members.integration.test.ts new file mode 100644 index 0000000..a742625 --- /dev/null +++ b/server/src/routes/workspace-members.integration.test.ts @@ -0,0 +1,141 @@ +import type { FastifyInstance } from 'fastify' +import { inArray } from 'drizzle-orm' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { buildApp } from '../app.js' +import { db } from '../db/client.js' +import { users, workspaces } from '../db/schema.js' + +interface Session { + accessToken: string + user: { id: string; email: string } + workspaces: Array<{ id: string; role: string }> +} + +describe.sequential('workspace member permissions', () => { + let app: FastifyInstance + let owner: Session + let editor: Session + let secondMember: Session + let workspaceId = '' + const userIds: string[] = [] + const ownedWorkspaceIds: string[] = [] + + async function inject(token: string, method: string, url: string, payload?: unknown) { + return app.inject({ + method, + url, + headers: { authorization: `Bearer ${token}` }, + payload, + }) + } + + async function request(token: string, method: string, url: string, payload?: unknown) { + const response = await inject(token, method, url, payload) + expect(response.statusCode, response.body).toBeLessThan(400) + return response.statusCode === 204 ? null : response.json() + } + + async function registerAccount(label: string): Promise { + const nonce = crypto.randomUUID().slice(0, 8) + const response = await app.inject({ + method: 'POST', + url: '/api/v1/auth/register', + payload: { + email: `${label}-${nonce}@frameflow.test`, + password: 'Integration2026!', + displayName: `${label} account`, + workspaceName: `${label} workspace ${nonce}`, + }, + }) + expect(response.statusCode, response.body).toBe(201) + const session = response.json() as Session + userIds.push(session.user.id) + ownedWorkspaceIds.push(session.workspaces[0].id) + return session + } + + beforeAll(async () => { + app = await buildApp() + owner = await registerAccount('owner') + editor = await registerAccount('editor') + secondMember = await registerAccount('member') + workspaceId = owner.workspaces[0].id + }) + + afterAll(async () => { + if (ownedWorkspaceIds.length) await db.delete(workspaces).where(inArray(workspaces.id, ownedWorkspaceIds)) + if (userIds.length) await db.delete(users).where(inArray(users.id, userIds)) + await app.close() + }) + + it('lets an owner add an existing account while editors can only list members', async () => { + const membership = await request(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: editor.user.email, + role: 'EDITOR', + }) + expect(membership.role).toBe('EDITOR') + + const members = await request(editor.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/members`) + expect(members.map((member: { userId: string }) => member.userId)).toEqual(expect.arrayContaining([owner.user.id, editor.user.id])) + + const availableWorkspaces = await request(editor.accessToken, 'GET', '/api/v1/workspaces') + expect(availableWorkspaces.map((workspace: { id: string }) => workspace.id)).toEqual(expect.arrayContaining([ + editor.workspaces[0].id, + workspaceId, + ])) + + const forbidden = await inject(editor.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: secondMember.user.email, + role: 'VIEWER', + }) + expect(forbidden.statusCode, forbidden.body).toBe(403) + expect(forbidden.json().error.code).toBe('FORBIDDEN') + }) + + it('lets an owner promote a member and an admin manage another non-owner member', async () => { + const promoted = await request(owner.accessToken, 'PATCH', `/api/v1/workspaces/${workspaceId}/members/${editor.user.id}`, { + role: 'ADMIN', + }) + expect(promoted.role).toBe('ADMIN') + + const added = await request(editor.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: secondMember.user.email, + role: 'VIEWER', + }) + expect(added.role).toBe('VIEWER') + + const updated = await request(editor.accessToken, 'PATCH', `/api/v1/workspaces/${workspaceId}/members/${secondMember.user.id}`, { + role: 'REVIEWER', + }) + expect(updated.role).toBe('REVIEWER') + }) + + it('protects the owner membership from upsert, role changes, and removal', async () => { + const upsert = await inject(owner.accessToken, 'POST', `/api/v1/workspaces/${workspaceId}/members`, { + email: owner.user.email, + role: 'ADMIN', + }) + expect(upsert.statusCode, upsert.body).toBe(403) + + const change = await inject(editor.accessToken, 'PATCH', `/api/v1/workspaces/${workspaceId}/members/${owner.user.id}`, { + role: 'VIEWER', + }) + expect(change.statusCode, change.body).toBe(403) + + const remove = await inject(editor.accessToken, 'DELETE', `/api/v1/workspaces/${workspaceId}/members/${owner.user.id}`) + expect(remove.statusCode, remove.body).toBe(403) + + const members = await request(owner.accessToken, 'GET', `/api/v1/workspaces/${workspaceId}/members`) + expect(members.find((member: { userId: string }) => member.userId === owner.user.id)?.role).toBe('OWNER') + }) + + it('prevents an admin from changing or removing their own membership', async () => { + const change = await inject(editor.accessToken, 'PATCH', `/api/v1/workspaces/${workspaceId}/members/${editor.user.id}`, { + role: 'VIEWER', + }) + expect(change.statusCode, change.body).toBe(403) + + const remove = await inject(editor.accessToken, 'DELETE', `/api/v1/workspaces/${workspaceId}/members/${editor.user.id}`) + expect(remove.statusCode, remove.body).toBe(403) + }) +}) diff --git a/server/src/routes/workspaces.ts b/server/src/routes/workspaces.ts new file mode 100644 index 0000000..17b0660 --- /dev/null +++ b/server/src/routes/workspaces.ts @@ -0,0 +1,327 @@ +import { randomBytes } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { and, desc, eq, isNull, sql } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '../db/client.js' +import { memberships, users, workspaceInvitations, workspaces } from '../db/schema.js' +import { hashToken, requireAuth } from '../lib/auth.js' +import { audit } from '../lib/audit.js' +import { errors } from '../lib/errors.js' +import { parseWith } from '../lib/http.js' +import { + resendInvitationMailer, + workspaceInvitationUrl, + type InvitationMailer, +} from '../lib/mailer.js' +import { requireWorkspacePermission } from '../lib/permissions.js' + +const workspaceParams = z.object({ workspaceId: z.uuid() }) +const memberParams = z.object({ workspaceId: z.uuid(), userId: z.uuid() }) +const invitationParams = z.object({ workspaceId: z.uuid(), invitationId: z.uuid() }) +const memberBody = z.object({ + email: z.email(), + role: z.enum(['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER']), +}) +const roleBody = z.object({ role: z.enum(['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER']) }) +const invitationBody = memberBody.extend({ + expiresInDays: z.number().int().min(1).max(30).default(7), +}) +const acceptInvitationBody = z.object({ + token: z.string().regex(/^[A-Za-z0-9_-]{43}$/, 'Invitation token is invalid'), +}) + +const invitationRoleLabels = { + ADMIN: '管理员', + EDITOR: '编辑', + REVIEWER: '审核员', + VIEWER: '访客', +} as const + +export interface WorkspaceRouteOptions { + invitationMailer?: InvitationMailer +} + +function invitationResponse(invitation: typeof workspaceInvitations.$inferSelect) { + return { + id: invitation.id, + workspaceId: invitation.workspaceId, + email: invitation.email, + role: invitation.role, + expiresAt: invitation.expiresAt, + acceptedAt: invitation.acceptedAt, + acceptedById: invitation.acceptedById, + revokedAt: invitation.revokedAt, + invitedById: invitation.invitedById, + createdAt: invitation.createdAt, + updatedAt: invitation.updatedAt, + status: invitation.acceptedAt + ? 'ACCEPTED' as const + : invitation.revokedAt + ? 'REVOKED' as const + : invitation.expiresAt.getTime() <= Date.now() + ? 'EXPIRED' as const + : 'PENDING' as const, + } +} + +export async function workspaceRoutes(app: FastifyInstance, options: WorkspaceRouteOptions = {}) { + const invitationMailer = options.invitationMailer ?? resendInvitationMailer + app.addHook('preHandler', requireAuth) + + app.get('/', async (request) => { + return db.select({ + id: workspaces.id, + name: workspaces.name, + slug: workspaces.slug, + plan: workspaces.plan, + monthlyCreditLimit: workspaces.monthlyCreditLimit, + role: memberships.role, + createdAt: workspaces.createdAt, + }).from(memberships).innerJoin(workspaces, eq(workspaces.id, memberships.workspaceId)).where(eq(memberships.userId, request.user.sub)) + }) + + app.get('/:workspaceId/members', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'project:read') + return db.select({ + userId: users.id, + email: users.email, + displayName: users.displayName, + avatarKey: users.avatarKey, + role: memberships.role, + joinedAt: memberships.joinedAt, + }).from(memberships).innerJoin(users, eq(users.id, memberships.userId)).where(eq(memberships.workspaceId, workspaceId)) + }) + + app.get('/:workspaceId/invitations', async (request) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + const rows = await db.select().from(workspaceInvitations).where(and( + eq(workspaceInvitations.workspaceId, workspaceId), + isNull(workspaceInvitations.acceptedAt), + isNull(workspaceInvitations.revokedAt), + )).orderBy(desc(workspaceInvitations.createdAt)) + return rows.map(invitationResponse) + }) + + app.post('/:workspaceId/invitations', { + config: { rateLimit: { max: 20, timeWindow: '10 minutes' } }, + }, async (request, reply) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(invitationBody, request.body) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + + const email = input.email.trim().toLowerCase() + const token = randomBytes(32).toString('base64url') + const now = new Date() + const expiresAt = new Date(now.getTime() + input.expiresInDays * 24 * 60 * 60 * 1000) + const [[workspace], [inviter]] = await Promise.all([ + db.select({ name: workspaces.name }).from(workspaces).where(eq(workspaces.id, workspaceId)).limit(1), + db.select({ email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, request.user.sub)).limit(1), + ]) + if (!workspace || !inviter) throw errors.notFound('Workspace invitation context') + const invitation = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`workspace-invitation:${workspaceId}:${email}`}))`) + const [account] = await tx.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1) + if (account) { + const [membership] = await tx.select({ role: memberships.role }).from(memberships).where(and( + eq(memberships.workspaceId, workspaceId), + eq(memberships.userId, account.id), + )).limit(1) + if (membership) throw errors.conflict('This account is already a workspace member') + } + + await tx.update(workspaceInvitations).set({ revokedAt: now, updatedAt: now }).where(and( + eq(workspaceInvitations.workspaceId, workspaceId), + eq(workspaceInvitations.email, email), + isNull(workspaceInvitations.acceptedAt), + isNull(workspaceInvitations.revokedAt), + )) + const [created] = await tx.insert(workspaceInvitations).values({ + workspaceId, + email, + role: input.role, + tokenHash: hashToken(token), + expiresAt, + invitedById: request.user.sub, + }).returning() + if (!created) throw new Error('Failed to create workspace invitation') + return created + }) + + const url = workspaceInvitationUrl(token) + let emailDelivery: { status: 'SENT' | 'NOT_CONFIGURED' | 'FAILED'; provider: 'resend' | 'development' | null } + try { + const delivery = await invitationMailer.send({ + to: invitation.email, + inviterName: inviter.displayName || inviter.email, + workspaceName: workspace.name, + roleLabel: invitationRoleLabels[input.role], + expiresAt: invitation.expiresAt, + url, + }) + emailDelivery = delivery.sent + ? { status: 'SENT', provider: delivery.provider } + : { status: 'NOT_CONFIGURED', provider: delivery.provider } + } catch (error) { + request.log.error({ err: error, invitationId: invitation.id }, 'Workspace invitation email delivery failed') + emailDelivery = { status: 'FAILED', provider: null } + } + + await audit({ + request, + workspaceId, + action: 'member.invitation.create', + resourceType: 'workspace_invitation', + resourceId: invitation.id, + after: { email: invitation.email, role: invitation.role, expiresAt: invitation.expiresAt.toISOString(), emailDeliveryStatus: emailDelivery.status }, + }) + return reply.code(201).send({ invitation: invitationResponse(invitation), token, emailDelivery }) + }) + + app.delete('/:workspaceId/invitations/:invitationId', async (request, reply) => { + const { workspaceId, invitationId } = parseWith(invitationParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + const [invitation] = await db.select().from(workspaceInvitations).where(and( + eq(workspaceInvitations.id, invitationId), + eq(workspaceInvitations.workspaceId, workspaceId), + )).limit(1) + if (!invitation) throw errors.notFound('Workspace invitation') + if (invitation.acceptedAt) throw errors.conflict('An accepted invitation cannot be revoked') + if (invitation.revokedAt) return reply.code(204).send() + + const revokedAt = new Date() + await db.update(workspaceInvitations).set({ revokedAt, updatedAt: revokedAt }).where(eq(workspaceInvitations.id, invitation.id)) + await audit({ + request, + workspaceId, + action: 'member.invitation.revoke', + resourceType: 'workspace_invitation', + resourceId: invitation.id, + before: { email: invitation.email, role: invitation.role, expiresAt: invitation.expiresAt.toISOString() }, + after: { revokedAt: revokedAt.toISOString() }, + }) + return reply.code(204).send() + }) + + app.post('/invitations/accept', { + config: { rateLimit: { max: 20, timeWindow: '1 minute' } }, + }, async (request, reply) => { + const { token } = parseWith(acceptInvitationBody, request.body) + const tokenHash = hashToken(token) + const acceptedAt = new Date() + const result = await db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`workspace-invitation-token:${tokenHash}`}))`) + const [row] = await tx.select({ invitation: workspaceInvitations, workspace: workspaces }).from(workspaceInvitations) + .innerJoin(workspaces, eq(workspaces.id, workspaceInvitations.workspaceId)) + .where(eq(workspaceInvitations.tokenHash, tokenHash)).limit(1) + if (!row) throw errors.badRequest('Workspace invitation is invalid') + if (row.invitation.acceptedAt) throw errors.conflict('Workspace invitation has already been accepted') + if (row.invitation.revokedAt) throw errors.badRequest('Workspace invitation has been revoked') + if (row.invitation.expiresAt.getTime() <= acceptedAt.getTime()) throw errors.badRequest('Workspace invitation has expired') + + const [account] = await tx.select({ id: users.id, email: users.email, disabledAt: users.disabledAt }).from(users) + .where(eq(users.id, request.user.sub)).limit(1) + if (!account || account.disabledAt) throw errors.unauthorized('Account is unavailable') + if (account.email.toLowerCase() !== row.invitation.email) { + throw errors.forbidden('The signed-in account does not match this invitation') + } + + await tx.insert(memberships).values({ + workspaceId: row.invitation.workspaceId, + userId: account.id, + role: row.invitation.role, + invitedById: row.invitation.invitedById, + }).onConflictDoNothing() + const [membership] = await tx.select().from(memberships).where(and( + eq(memberships.workspaceId, row.invitation.workspaceId), + eq(memberships.userId, account.id), + )).limit(1) + if (!membership) throw new Error('Failed to create invitation membership') + + const [accepted] = await tx.update(workspaceInvitations).set({ + acceptedAt, + acceptedById: account.id, + updatedAt: acceptedAt, + }).where(eq(workspaceInvitations.id, row.invitation.id)).returning() + if (!accepted) throw new Error('Failed to accept workspace invitation') + return { invitation: accepted, workspace: row.workspace, membership } + }) + + await audit({ + request, + workspaceId: result.workspace.id, + action: 'member.invitation.accept', + resourceType: 'workspace_invitation', + resourceId: result.invitation.id, + after: { email: result.invitation.email, role: result.membership.role, acceptedAt: acceptedAt.toISOString() }, + }) + return reply.code(201).send({ + invitation: invitationResponse(result.invitation), + workspace: { + id: result.workspace.id, + name: result.workspace.name, + slug: result.workspace.slug, + plan: result.workspace.plan, + monthlyCreditLimit: result.workspace.monthlyCreditLimit, + role: result.membership.role, + }, + }) + }) + + app.post('/:workspaceId/members', async (request, reply) => { + const { workspaceId } = parseWith(workspaceParams, request.params) + const input = parseWith(memberBody, request.body) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, input.email.toLowerCase())).limit(1) + if (!user) throw errors.notFound('User account') + const [existingMembership] = await db.select({ role: memberships.role }).from(memberships).where(and( + eq(memberships.workspaceId, workspaceId), + eq(memberships.userId, user.id), + )).limit(1) + if (existingMembership?.role === 'OWNER') throw errors.forbidden('The workspace owner role cannot be changed') + + const [membership] = await db.insert(memberships).values({ + workspaceId, + userId: user.id, + role: input.role, + invitedById: request.user.sub, + }).onConflictDoUpdate({ + target: [memberships.workspaceId, memberships.userId], + set: { role: input.role, updatedAt: new Date() }, + }).returning() + + await audit({ request, workspaceId, action: 'member.upsert', resourceType: 'membership', resourceId: user.id, after: { role: input.role } }) + return reply.code(201).send(membership) + }) + + app.patch('/:workspaceId/members/:userId', async (request) => { + const { workspaceId, userId } = parseWith(memberParams, request.params) + const input = parseWith(roleBody, request.body) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + if (userId === request.user.sub) throw errors.forbidden('You cannot change your own workspace role') + + const [existing] = await db.select().from(memberships).where(and(eq(memberships.workspaceId, workspaceId), eq(memberships.userId, userId))).limit(1) + if (!existing) throw errors.notFound('Membership') + if (existing.role === 'OWNER') throw errors.forbidden('The workspace owner role cannot be changed') + + const [updated] = await db.update(memberships).set({ role: input.role, updatedAt: new Date() }).where(and( + eq(memberships.workspaceId, workspaceId), + eq(memberships.userId, userId), + )).returning() + await audit({ request, workspaceId, action: 'member.role.update', resourceType: 'membership', resourceId: userId, before: { role: existing.role }, after: { role: input.role } }) + return updated + }) + + app.delete('/:workspaceId/members/:userId', async (request, reply) => { + const { workspaceId, userId } = parseWith(memberParams, request.params) + await requireWorkspacePermission(request, workspaceId, 'members:manage') + if (userId === request.user.sub) throw errors.forbidden('You cannot remove yourself from the workspace') + const [existing] = await db.select().from(memberships).where(and(eq(memberships.workspaceId, workspaceId), eq(memberships.userId, userId))).limit(1) + if (!existing) throw errors.notFound('Membership') + if (existing.role === 'OWNER') throw errors.forbidden('The workspace owner cannot be removed') + await db.delete(memberships).where(and(eq(memberships.workspaceId, workspaceId), eq(memberships.userId, userId))) + await audit({ request, workspaceId, action: 'member.remove', resourceType: 'membership', resourceId: userId, before: { role: existing.role } }) + return reply.code(204).send() + }) +} diff --git a/server/src/storage/client.test.ts b/server/src/storage/client.test.ts new file mode 100644 index 0000000..0562792 --- /dev/null +++ b/server/src/storage/client.test.ts @@ -0,0 +1,14 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { closeStorage, projectSeedObjectKey } from './client.js' + +afterAll(() => closeStorage()) + +describe('project seed object keys', () => { + it('keeps bundled media inside the owning project namespace', () => { + expect(projectSeedObjectKey( + '61380d0c-2992-402e-8468-dc54b7bc5903', + '457b73f9-4e79-4e9a-b98d-91b943ee6cb1', + '../../Character 01.JPG', + )).toBe('61380d0c-2992-402e-8468-dc54b7bc5903/457b73f9-4e79-4e9a-b98d-91b943ee6cb1/seed/Character-01.jpg') + }) +}) diff --git a/server/src/storage/client.ts b/server/src/storage/client.ts new file mode 100644 index 0000000..c553c1a --- /dev/null +++ b/server/src/storage/client.ts @@ -0,0 +1,105 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { config } from '../config.js' + +function createStorageClient(endpoint: string) { + return new S3Client({ + endpoint, + region: config.S3_REGION, + forcePathStyle: config.S3_FORCE_PATH_STYLE, + credentials: { + accessKeyId: config.S3_ACCESS_KEY, + secretAccessKey: config.S3_SECRET_KEY, + }, + }) +} + +export const storage = createStorageClient(config.S3_ENDPOINT) +const signingStorage = config.S3_PUBLIC_ENDPOINT && config.S3_PUBLIC_ENDPOINT !== config.S3_ENDPOINT + ? createStorageClient(config.S3_PUBLIC_ENDPOINT) + : storage + +export function closeStorage() { + storage.destroy() + if (signingStorage !== storage) signingStorage.destroy() +} + +export async function checkStorage() { + await storage.send(new HeadBucketCommand({ Bucket: config.S3_BUCKET })) +} + +export async function putObject(input: { + key: string + body: Buffer | Uint8Array | string + contentType: string + metadata?: Record +}) { + await storage.send(new PutObjectCommand({ + Bucket: config.S3_BUCKET, + Key: input.key, + Body: input.body, + ContentType: input.contentType, + Metadata: input.metadata, + })) + return { key: input.key, bucket: config.S3_BUCKET } +} + +export async function deleteObject(key: string) { + await storage.send(new DeleteObjectCommand({ Bucket: config.S3_BUCKET, Key: key })) +} + +export async function getObjectBuffer(key: string) { + const response = await storage.send(new GetObjectCommand({ Bucket: config.S3_BUCKET, Key: key })) + if (!response.Body) throw new Error(`Object ${key} has no body`) + const bytes = await response.Body.transformToByteArray() + return Buffer.from(bytes) +} + +export async function presignUpload(key: string, contentType: string, expiresIn = 900) { + return getSignedUrl(signingStorage, new PutObjectCommand({ + Bucket: config.S3_BUCKET, + Key: key, + ContentType: contentType, + }), { expiresIn }) +} + +export async function presignDownload(key: string, expiresIn = 900, filename?: string) { + const encodedFilename = filename + ? encodeURIComponent(filename).replace(/['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + : undefined + const disposition = filename + ? `attachment; filename="${safeObjectName(filename)}"; filename*=UTF-8''${encodedFilename}` + : undefined + return getSignedUrl(signingStorage, new GetObjectCommand({ + Bucket: config.S3_BUCKET, + Key: key, + ...(disposition ? { ResponseContentDisposition: disposition } : {}), + }), { expiresIn }) +} + +export function safeObjectName(filename: string) { + const parts = filename.split('.') + const extension = parts.length > 1 ? `.${parts.pop()!.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10)}` : '' + const stem = parts.join('.').normalize('NFKD').replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'upload' + return `${stem}${extension}` +} + +export function projectObjectKey(workspaceId: string, projectId: string, filename: string) { + return `${workspaceId}/${projectId}/uploads/${crypto.randomUUID()}-${safeObjectName(filename)}` +} + +export function projectSeedObjectKey(workspaceId: string, projectId: string, filename: string) { + return `${workspaceId}/${projectId}/seed/${safeObjectName(filename)}` +} + +export function generatedObjectKey(workspaceId: string, projectId: string, category: string, extension: string) { + const safeCategory = category.replace(/[^a-z0-9_-]/gi, '-').toLowerCase() + const safeExtension = extension.replace(/[^a-z0-9]/gi, '').toLowerCase() + return `${workspaceId}/${projectId}/generated/${safeCategory}/${crypto.randomUUID()}.${safeExtension}` +} diff --git a/server/src/types/fastify.d.ts b/server/src/types/fastify.d.ts new file mode 100644 index 0000000..9090b56 --- /dev/null +++ b/server/src/types/fastify.d.ts @@ -0,0 +1,18 @@ +import '@fastify/jwt' + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { + sub: string + email: string + displayName: string + tokenType: 'access' + } + user: { + sub: string + email: string + displayName: string + tokenType: 'access' + } + } +} diff --git a/server/src/worker.ts b/server/src/worker.ts new file mode 100644 index 0000000..5f67ea0 --- /dev/null +++ b/server/src/worker.ts @@ -0,0 +1,251 @@ +import { createServer } from 'node:http' +import { hostname } from 'node:os' +import { UnrecoverableError, Worker, type Job } from 'bullmq' +import { and, eq, inArray, ne, sql } from 'drizzle-orm' +import pino from 'pino' +import { config } from './config.js' +import { closeDatabase, db } from './db/client.js' +import { generationJobs, productionRuns, usageLedger } from './db/schema.js' +import { generationJobDurationSeconds, generationJobQueueDelaySeconds, generationJobsProcessedTotal, generationWorkerActiveJobs, generationWorkerHeartbeatTimestampSeconds, metricsRegistry } from './lib/metrics.js' +import { notifyJobTerminal } from './lib/notifications.js' +import { publishRealtimeEvent } from './lib/realtime.js' +import { createRedisConnection } from './queue/connection.js' +import { publishWorkerHeartbeat } from './queue/heartbeat.js' +import { JobCancelledError, processGenerationJob } from './queue/processors.js' +import { GENERATION_QUEUE, type GenerationJobData, type GenerationJobResult, type JobKind } from './queue/types.js' + +const connection = createRedisConnection() +const heartbeatConnection = createRedisConnection() +const logger = pino({ level: config.LOG_LEVEL, base: { service: 'frameflow-worker', environment: config.NODE_ENV } }) +const workerInstanceId = `${hostname()}:${process.pid}` +let workerReady = false +let lastHeartbeatAt = 0 +let heartbeatTimer: NodeJS.Timeout | null = null + +const worker = new Worker(GENERATION_QUEUE, async (job: Job) => { + const stopTimer = generationJobDurationSeconds.startTimer({ type: job.data.type }) + generationJobQueueDelaySeconds.observe({ type: job.data.type }, Math.max(0, (Date.now() - job.timestamp) / 1000)) + generationWorkerActiveJobs.inc() + logger.info({ event: 'job.started', jobId: job.id, databaseId: job.data.databaseId, type: job.data.type, attempt: job.attemptsMade + 1 }, 'Generation job started') + try { + const [record] = await db.select({ status: generationJobs.status, productionRunId: generationJobs.productionRunId }).from(generationJobs) + .where(eq(generationJobs.id, job.data.databaseId)).limit(1) + if (!record || record.status === 'CANCELLED') { + generationJobsProcessedTotal.inc({ type: job.data.type, result: 'cancelled' }) + throw new UnrecoverableError('Generation job was cancelled') + } + + if (record.productionRunId) { + await db.update(productionRuns).set({ + status: 'RUNNING', + startedAt: sql`coalesce(${productionRuns.startedAt}, now())`, + updatedAt: new Date(), + }).where(and(eq(productionRuns.id, record.productionRunId), eq(productionRuns.status, 'QUEUED'))) + } + + const [started] = await db.update(generationJobs).set({ + status: 'RUNNING', + progress: 1, + attempts: job.attemptsMade + 1, + startedAt: new Date(), + updatedAt: new Date(), + }).where(and(eq(generationJobs.id, job.data.databaseId), inArray(generationJobs.status, ['QUEUED', 'RUNNING']))).returning({ id: generationJobs.id }) + if (!started) throw new UnrecoverableError('Generation job was cancelled') + + await publishRealtimeEvent({ + workspaceId: job.data.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: job.data.databaseId, + payload: { id: job.data.databaseId, type: job.data.type, status: 'RUNNING', progress: 1 }, + }) + + await job.updateProgress(5) + await db.update(generationJobs).set({ progress: 5, updatedAt: new Date() }) + .where(and(eq(generationJobs.id, job.data.databaseId), ne(generationJobs.status, 'CANCELLED'))) + await publishRealtimeEvent({ + workspaceId: job.data.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: job.data.databaseId, + payload: { id: job.data.databaseId, type: job.data.type, status: 'RUNNING', progress: 5 }, + }) + + try { + const result = await processGenerationJob(job.data) + await job.updateProgress(100) + await db.transaction(async (tx) => { + const [completed] = await tx.update(generationJobs).set({ + status: 'SUCCEEDED', + progress: 100, + provider: result.provider, + providerModel: result.providerModel, + output: result.output, + completedAt: new Date(), + updatedAt: new Date(), + errorCode: null, + errorMessage: null, + }).where(and(eq(generationJobs.id, job.data.databaseId), ne(generationJobs.status, 'CANCELLED'))).returning({ id: generationJobs.id }) + if (completed && result.usage?.length) { + await tx.insert(usageLedger).values(result.usage.map((entry) => ({ + workspaceId: job.data.workspaceId, + userId: job.data.userId, + jobId: job.data.databaseId, + kind: entry.kind, + quantity: entry.quantity.toString(), + credits: entry.credits.toString(), + providerCostUsd: entry.providerCostUsd?.toString(), + }))) + } + if (completed && record.productionRunId && job.data.type === 'EPISODE_RENDER') { + await tx.update(productionRuns).set({ status: 'SUCCEEDED', completedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(productionRuns.id, record.productionRunId), inArray(productionRuns.status, ['QUEUED', 'RUNNING']))) + } + }) + await Promise.all([ + publishRealtimeEvent({ + workspaceId: job.data.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: job.data.databaseId, + payload: { id: job.data.databaseId, type: job.data.type, status: 'SUCCEEDED', progress: 100 }, + }), + notifyJobTerminal({ + workspaceId: job.data.workspaceId, + userId: job.data.userId, + jobId: job.data.databaseId, + jobType: job.data.type, + status: 'SUCCEEDED', + output: result.output, + ...(job.data.projectId ? { projectId: job.data.projectId } : {}), + ...(job.data.episodeId ? { episodeId: job.data.episodeId } : {}), + }).catch((notificationError) => { + logger.error({ event: 'notification.create.failed', err: notificationError, jobId: job.data.databaseId }, 'Job completion notification failed') + return null + }), + ]) + generationJobsProcessedTotal.inc({ type: job.data.type, result: 'succeeded' }) + return result + } catch (error) { + generationJobsProcessedTotal.inc({ type: job.data.type, result: error instanceof JobCancelledError ? 'cancelled' : 'failed' }) + if (error instanceof JobCancelledError) throw new UnrecoverableError(error.message) + const exhausted = job.attemptsMade + 1 >= (job.opts.attempts ?? 1) + await db.update(generationJobs).set({ + status: exhausted ? 'FAILED' : 'QUEUED', + errorCode: 'PROCESSOR_ERROR', + errorMessage: error instanceof Error ? error.message : 'Worker failed', + completedAt: exhausted ? new Date() : null, + updatedAt: new Date(), + }).where(and(eq(generationJobs.id, job.data.databaseId), ne(generationJobs.status, 'CANCELLED'))) + if (exhausted && record.productionRunId && job.data.type === 'EPISODE_RENDER') { + await db.update(productionRuns).set({ status: 'FAILED', completedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(productionRuns.id, record.productionRunId), inArray(productionRuns.status, ['QUEUED', 'RUNNING']))) + } + await publishRealtimeEvent({ + workspaceId: job.data.workspaceId, + type: 'job.updated', + resourceType: 'generation_job', + resourceId: job.data.databaseId, + payload: { + id: job.data.databaseId, + type: job.data.type, + status: exhausted ? 'FAILED' : 'QUEUED', + progress: 5, + errorMessage: error instanceof Error ? error.message : 'Worker failed', + }, + }) + if (exhausted) { + await notifyJobTerminal({ + workspaceId: job.data.workspaceId, + userId: job.data.userId, + jobId: job.data.databaseId, + jobType: job.data.type, + status: 'FAILED', + errorMessage: error instanceof Error ? error.message : 'Worker failed', + ...(job.data.projectId ? { projectId: job.data.projectId } : {}), + ...(job.data.episodeId ? { episodeId: job.data.episodeId } : {}), + }).catch((notificationError) => { + logger.error({ event: 'notification.create.failed', err: notificationError, jobId: job.data.databaseId }, 'Job failure notification failed') + }) + } + throw error + } + } finally { + generationWorkerActiveJobs.dec() + stopTimer() + } +}, { + connection, + concurrency: config.WORKER_CONCURRENCY, + lockDuration: 10 * 60 * 1000, +}) + +async function writeHeartbeat() { + try { + const heartbeat = await publishWorkerHeartbeat({ client: heartbeatConnection, instanceId: workerInstanceId }) + lastHeartbeatAt = Date.parse(heartbeat.updatedAt) + generationWorkerHeartbeatTimestampSeconds.set(lastHeartbeatAt / 1000) + } catch (error) { + logger.error({ event: 'worker.heartbeat.failed', err: error }, 'Worker heartbeat write failed') + } +} + +worker.on('ready', () => { + workerReady = true + void writeHeartbeat() + if (!heartbeatTimer) { + heartbeatTimer = setInterval(() => void writeHeartbeat(), config.WORKER_HEARTBEAT_INTERVAL_MS) + heartbeatTimer.unref() + } + logger.info({ event: 'worker.ready', concurrency: config.WORKER_CONCURRENCY, instanceId: workerInstanceId }, 'FrameFlow worker ready') +}) +worker.on('completed', (job) => logger.info({ event: 'job.completed', jobId: job.id, type: job.name, attempts: job.attemptsMade + 1 }, 'Generation job completed')) +worker.on('failed', (job, error) => logger.error({ event: 'job.failed', jobId: job?.id, type: job?.name, attempts: job ? job.attemptsMade + 1 : undefined, err: error }, 'Generation job failed')) +worker.on('error', (error) => logger.error({ event: 'worker.error', err: error }, 'Worker connection error')) + +const metricsServer = createServer(async (request, response) => { + const path = request.url?.split('?')[0] + if (path === '/health/live') { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify({ status: 'ok', service: 'frameflow-worker' })) + return + } + if (path === '/health/ready') { + const heartbeatAgeMs = lastHeartbeatAt ? Date.now() - lastHeartbeatAt : null + const ready = workerReady && heartbeatAgeMs !== null && heartbeatAgeMs <= config.WORKER_HEARTBEAT_TTL_SECONDS * 1000 + response.writeHead(ready ? 200 : 503, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify({ + status: ready ? 'ready' : 'not_ready', + service: 'frameflow-worker', + instanceId: workerInstanceId, + heartbeatAgeMs, + })) + return + } + if (path !== '/metrics') { + response.writeHead(404).end('Not found') + return + } + if (config.METRICS_TOKEN && request.headers.authorization !== `Bearer ${config.METRICS_TOKEN}`) { + response.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }).end('Unauthorized') + return + } + response.writeHead(200, { 'content-type': metricsRegistry.contentType }) + response.end(await metricsRegistry.metrics()) +}) + +metricsServer.listen(config.WORKER_METRICS_PORT, config.METRICS_HOST, () => { + logger.info({ event: 'metrics.ready', host: config.METRICS_HOST, port: config.WORKER_METRICS_PORT }, 'Worker metrics endpoint ready') +}) + +async function shutdown(signal: string) { + logger.info({ event: 'worker.shutdown', signal }, 'Worker shutting down') + workerReady = false + if (heartbeatTimer) clearInterval(heartbeatTimer) + await new Promise((resolve, reject) => metricsServer.close((error) => error ? reject(error) : resolve())) + await worker.close() + await Promise.all([connection.quit(), heartbeatConnection.quit()]) + await closeDatabase() + process.exit(0) +} + +process.on('SIGINT', () => void shutdown('SIGINT')) +process.on('SIGTERM', () => void shutdown('SIGTERM')) diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..7760bee --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..5b7e64f --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,4234 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + AlertCircle, + ArrowLeft, + ArrowRight, + ArrowUpRight, + Bell, + BookOpenText, + Box, + CalendarDays, + Captions, + Check, + CheckCheck, + CheckCircle2, + ChevronDown, + ChevronLeft, + ChevronRight, + CircleDashed, + ClipboardCheck, + Clapperboard, + Cloud, + Command, + Copy, + Cpu, + CreditCard, + Database, + Download, + Eye, + FileText, + Film, + Gauge, + GitCompare, + Grid2X2, + History, + HardDrive, + Image, + KeyRound, + Laptop, + Layers3, + LayoutDashboard, + List, + LockKeyhole, + LogOut, + Map, + Maximize2, + Menu, + MessageSquareText, + Mic2, + MonitorPlay, + Music2, + PanelRightClose, + PanelsTopLeft, + Pause, + Play, + Plus, + RefreshCw, + Save, + Search, + Send, + Server, + Share2, + ShieldCheck, + Shapes, + SlidersHorizontal, + Smartphone, + Sparkles, + SquarePen, + Trash2, + Undo2, + Upload, + UserPlus, + Users, + Volume2, + WandSparkles, + X, + Zap, + ZoomIn, +} from 'lucide-react' +import { api, ApiError, login, logout, register, restoreSession, setSessionExpiredHandler, subscribeRealtime } from './api' +import { findAiCapability, imageProviderChoices, preferredImageProvider, preferredVideoProvider, preferredWorkflowProvider, providerVerificationState, videoProviderChoices } from './aiCapabilities' +import { projectHealthSnapshot } from './projectHealth' +import { clipTrimFromDrag, locateTimelinePosition, timelineDuration } from './timeline' + +const navItems = [ + { id: 'overview', label: '创作总览', icon: LayoutDashboard }, + { id: 'assets', label: '资产中心', icon: Shapes }, + { id: 'script', label: '剧本工坊', icon: BookOpenText }, + { id: 'storyboard', label: '智能分镜', icon: PanelsTopLeft }, + { id: 'production', label: '制作中心', icon: Clapperboard }, + { id: 'jobs', label: '任务中心', icon: CircleDashed }, + { id: 'services', label: 'AI 服务', icon: Cpu }, + { id: 'reviews', label: '审核中心', icon: ClipboardCheck }, + { id: 'publish', label: '发布管理', icon: Send }, + { id: 'team', label: '团队与权限', icon: Users }, + { id: 'audit', label: '操作日志', icon: ShieldCheck, adminOnly: true }, +] + +const fallbackShots = [ + { id: '01', scene: '旧城天台', duration: '3.2s', type: '全景', img: '/assets/scene-rooftop.jpg', copy: '暴雨掠过旧城天台,霓虹在积水中摇晃。', status: '完成' }, + { id: '02', scene: '旧城天台', duration: '2.6s', type: '中景', img: '/assets/character-linyao.jpg', copy: '林遥推开铁门,抬手遮住刺眼的探照灯。', status: '完成' }, + { id: '03', scene: '旧城天台', duration: '2.1s', type: '特写', img: '/assets/character-lucheng.jpg', copy: '陆澄回头,雨水沿着侧脸落下。', status: '完成' }, + { id: '04', scene: '旧城天台', duration: '4.0s', type: '双人', img: '/assets/scene-rooftop.jpg', copy: '两人隔着雨幕对峙,远处钟声响起。', status: '待生成' }, + { id: '05', scene: '雾城街道', duration: '3.6s', type: '航拍', img: '/assets/scene-city.jpg', copy: '镜头越过密集楼宇,追向一辆黑色轿车。', status: '待生成' }, + { id: '06', scene: '林遥工作室', duration: '2.8s', type: '近景', img: '/assets/scene-studio.jpg', copy: '录音机自动亮起,红灯在暗室中闪烁。', status: '草稿' }, +] + +const assetTypeLabels = { CHARACTER: '人物', SCENE: '场景', PROP: '道具', VOICE: '音色', MUSIC: '音乐', SHOT: '镜头' } +const assetTypeValues = { 人物: 'CHARACTER', 场景: 'SCENE', 道具: 'PROP', 音色: 'VOICE', 音乐: 'MUSIC' } +const assetStatusLabels = { DRAFT: '草稿', GENERATING: '生成中', REVIEW: '待审核', APPROVED: '已定稿', ARCHIVED: '已归档' } +const shotStatusLabels = { DRAFT: '草稿', GENERATING: '生成中', REVIEW: '待审核', APPROVED: '完成', RENDERED: '完成' } +const renderStatusLabels = { QUEUED: '排队中', RENDERING: '渲染中', REVIEW: '待审核', APPROVED: '已批准', FAILED: '失败' } +const deliveryStatusLabels = { DRAFT: '草稿', QUEUED: '排队中', PUBLISHING: '交付中', PUBLISHED: '已发布', FAILED: '失败', CANCELLED: '已取消' } +const publishingPlatformLabels = { DOWNLOAD: '下载交付', DOUYIN: '抖音', KUAISHOU: '快手', BILIBILI: 'B 站', XIAOHONGSHU: '小红书' } +const reviewStatusLabels = { OPEN: '待处理', APPROVED: '已通过', CHANGES_REQUESTED: '已退回', CANCELLED: '已取消' } +const reviewTargetLabels = { SCRIPT_VERSION: '剧本版本', ASSET_VERSION: '资产版本', SHOT_VERSION: '镜头版本', RENDER: '成片版本' } +const usageKindLabels = { TEXT_INPUT_TOKEN: '文本输入', TEXT_OUTPUT_TOKEN: '文本输出', IMAGE: '图片生成', AUDIO_SECOND: '语音生成', VIDEO_SECOND: '视频生成', RENDER_SECOND: '成片渲染', STORAGE_BYTE_MONTH: '对象存储' } +const jobTypeLabels = { PIPELINE_HEALTHCHECK: '管线检查', SCRIPT_GENERATE: '剧本生成', STORYBOARD_GENERATE: '智能分镜', ASSET_GENERATE: '画面生成', TTS_GENERATE: '配音生成', VIDEO_GENERATE: '视频生成', LIPSYNC_GENERATE: '口型同步', EPISODE_RENDER: '成片渲染', DELIVERY_PUBLISH: '发布交付' } +const workflowJobTypes = { script: 'SCRIPT_GENERATE', storyboard: 'STORYBOARD_GENERATE', image: 'ASSET_GENERATE', tts: 'TTS_GENERATE', video: 'VIDEO_GENERATE', lipsync: 'LIPSYNC_GENERATE' } +const jobStatusLabels = { QUEUED: '排队中', RUNNING: '执行中', SUCCEEDED: '已完成', FAILED: '失败', CANCELLED: '已取消' } +const productionRunStatusLabels = { QUEUED: '排队中', RUNNING: '生产中', SUCCEEDED: '已完成', FAILED: '失败', CANCELLED: '已取消' } +const billingSubscriptionStatusLabels = { no_subscription: '未开通', incomplete: '待完成支付', incomplete_expired: '支付已过期', trialing: '试用中', active: '生效中', past_due: '待补款', canceled: '已取消', unpaid: '未支付', paused: '已暂停' } +const roleLabels = { OWNER: '所有者', ADMIN: '管理员', EDITOR: '编辑', REVIEWER: '审核员', VIEWER: '访客' } +const auditCategoryOptions = [ + ['ALL', '全部操作'], + ['auth', '账户安全'], + ['member', '成员权限'], + ['project', '项目'], + ['episode', '分集'], + ['script', '剧本'], + ['asset', '资产'], + ['shot', '镜头'], + ['job', '任务'], + ['production_run', '整集生产'], + ['review', '审核'], + ['render', '渲染'], + ['delivery', '交付'], + ['billing', '计费'], + ['upload', '上传'], + ['service', '服务验证'], +] +const auditActionLabels = { + 'auth.register': '创建账号', + 'auth.sessions.revoke_others': '下线其他设备', + 'auth.session.revoke': '下线登录设备', + 'auth.email.verify': '验证邮箱', + 'auth.password.change': '修改密码', + 'auth.password.reset': '重置密码', + 'member.upsert': '添加或更新成员', + 'member.role.update': '修改成员角色', + 'member.remove': '移除成员', + 'member.invitation.create': '创建成员邀请', + 'member.invitation.revoke': '撤销成员邀请', + 'member.invitation.accept': '接受成员邀请', + 'project.create': '创建项目', + 'project.update': '更新项目', + 'project.archive': '归档项目', + 'episode.create': '创建分集', + 'episode.update': '更新分集', + 'script.version.create': '保存剧本版本', + 'script.version.activate': '切换剧本版本', + 'asset.create': '创建资产', + 'asset.update': '更新资产', + 'asset.consistency_profile.update': '更新角色档案', + 'asset.version.create': '创建资产版本', + 'asset.version.activate': '切换资产版本', + 'shot.create': '创建镜头', + 'shot.update': '更新镜头', + 'shot.delete': '删除镜头', + 'shot.production.update': '更新镜头制作', + 'shot.version.create': '创建镜头版本', + 'shot.version.activate': '切换镜头版本', + 'shots.reorder': '调整镜头顺序', + 'job.enqueue': '提交生成任务', + 'job.cancel': '取消任务', + 'job.retry': '重试任务', + 'review.create': '提交审核', + 'review.comment.create': '添加审核意见', + 'review.assign': '指派审核人', + 'review.decide': '完成审核决策', + 'render.create': '创建渲染', + 'production_run.create': '启动整集生产', + 'production_run.cancel': '取消整集生产', + 'delivery.create': '创建交付', + 'delivery.cancel': '取消交付', + 'delivery.retry': '重试交付', + 'billing.checkout.create': '创建订阅结账', + 'billing.portal.create': '打开客户门户', + 'billing.webhook.process': '同步订阅状态', + 'service.provider.verify': '验证 AI 服务连接', + 'upload.presign': '申请上传地址', + 'upload.create': '上传文件', +} +const auditResourceLabels = { membership: '成员', workspace_invitation: '成员邀请', project: '项目', episode: '分集', script_version: '剧本版本', asset: '资产', asset_version: '资产版本', shot: '镜头', shot_version: '镜头版本', generation_job: '任务', production_run: '整集生产', review_request: '审核', review_comment: '审核意见', render: '成片', delivery: '交付', billing_subscription: '订阅', provider_verification: 'AI 服务', object: '文件' } + +function fallbackAssetImage(asset) { + if (asset.type === 'CHARACTER') return asset.name.includes('陆澄') ? '/assets/character-lucheng.jpg' : asset.name.includes('苏玥') ? '/assets/character-suyue.jpg' : '/assets/character-linyao.jpg' + if (asset.type === 'SCENE') return asset.name.includes('工作室') ? '/assets/scene-studio.jpg' : asset.name.includes('城市') || asset.name.includes('中心') ? '/assets/scene-city.jpg' : '/assets/scene-rooftop.jpg' + return '/assets/scene-studio.jpg' +} + +function formatDuration(milliseconds = 0) { + const seconds = Math.max(0, Math.round(milliseconds / 1000)) + return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}` +} + +function formatDateTimeLocal(date) { + const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000) + return local.toISOString().slice(0, 16) +} + +function formatBytes(bytes) { + if (!bytes) return '等待生成' + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} + +function clampNumber(value, minimum, maximum, fallback = minimum) { + const parsed = Number(value) + return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, parsed)) : fallback +} + +function parseAssetTags(value) { + return [...new Set(value.split(/[,,\n]/).map((tag) => tag.trim()).filter(Boolean))].slice(0, 30) +} + +function errorMessage(error) { + if (error instanceof ApiError) return error.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +function invitationTokenFromLocation() { + return new URL(window.location.href).searchParams.get('invite') +} + +function invitationUrl(token) { + const url = new URL(window.location.href) + url.search = '' + url.hash = '' + url.searchParams.set('invite', token) + return url.toString() +} + +function clearInvitationFromLocation() { + const url = new URL(window.location.href) + url.searchParams.delete('invite') + window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`) +} + +function billingReturnFromLocation() { + return new URL(window.location.href).searchParams.get('billing') +} + +function clearBillingReturnFromLocation() { + const url = new URL(window.location.href) + url.searchParams.delete('billing') + url.searchParams.delete('session_id') + window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`) +} + +function publishingReturnFromLocation() { + return new URL(window.location.href).searchParams.get('publishing') +} + +function clearPublishingReturnFromLocation() { + const url = new URL(window.location.href) + url.searchParams.delete('publishing') + window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`) +} + +function App() { + const [session, setSession] = useState(null) + const [authLoading, setAuthLoading] = useState(true) + const [page, setPage] = useState('overview') + const [mobileNav, setMobileNav] = useState(false) + const [commandOpen, setCommandOpen] = useState(false) + const [generatorOpen, setGeneratorOpen] = useState(false) + const [generatorTarget, setGeneratorTarget] = useState(null) + const [projectOpen, setProjectOpen] = useState(false) + const [projectMenuOpen, setProjectMenuOpen] = useState(false) + const [workspaceId, setWorkspaceId] = useState(null) + const [projects, setProjects] = useState([]) + const [projectId, setProjectId] = useState(null) + const [project, setProject] = useState(null) + const [episodeId, setEpisodeId] = useState(null) + const [episode, setEpisode] = useState(null) + const [assets, setAssets] = useState([]) + const [assetVersionMedia, setAssetVersionMedia] = useState({}) + const [studioShots, setStudioShots] = useState([]) + const [jobs, setJobs] = useState([]) + const [renders, setRenders] = useState([]) + const [deliveries, setDeliveries] = useState([]) + const [publishingChannels, setPublishingChannels] = useState({ canManage: false, platforms: [] }) + const [productionRuns, setProductionRuns] = useState([]) + const [reviews, setReviews] = useState([]) + const [members, setMembers] = useState([]) + const [invitations, setInvitations] = useState([]) + const [inviteToken, setInviteToken] = useState(invitationTokenFromLocation) + const [inviteAccepting, setInviteAccepting] = useState(false) + const [versionTarget, setVersionTarget] = useState(null) + const [billing, setBilling] = useState(null) + const [serviceReadiness, setServiceReadiness] = useState(null) + const [billingOpen, setBillingOpen] = useState(false) + const [securityOpen, setSecurityOpen] = useState(false) + const [projectSettingsOpen, setProjectSettingsOpen] = useState(false) + const [episodeSettingsOpen, setEpisodeSettingsOpen] = useState(false) + const [notificationOpen, setNotificationOpen] = useState(false) + const [notifications, setNotifications] = useState([]) + const [notificationUnreadCount, setNotificationUnreadCount] = useState(0) + const [realtimeStatus, setRealtimeStatus] = useState('connecting') + const [dataLoading, setDataLoading] = useState(false) + const [toast, setToast] = useState('') + const invitationAttemptRef = useRef(null) + const billingReturnAttemptRef = useRef(null) + const publishingReturnAttemptRef = useRef(null) + + const showToast = useCallback((message) => { + setToast(message) + window.setTimeout(() => setToast(''), 3200) + }, []) + + useEffect(() => { + setSessionExpiredHandler(() => { + setSession(null) + setWorkspaceId(null) + setProjects([]) + setProjectId(null) + setProject(null) + setEpisodeId(null) + setEpisode(null) + setAssets([]) + setStudioShots([]) + setJobs([]) + setRenders([]) + setDeliveries([]) + setPublishingChannels({ canManage: false, platforms: [] }) + setProductionRuns([]) + setReviews([]) + setNotifications([]) + setNotificationUnreadCount(0) + setRealtimeStatus('closed') + setMembers([]) + setInvitations([]) + setBilling(null) + setServiceReadiness(null) + showToast('登录已过期,请重新登录') + }) + return () => setSessionExpiredHandler(null) + }, [showToast]) + + useEffect(() => { + let active = true + restoreSession() + .then((restored) => { + if (!active || !restored) return + setSession(restored) + setWorkspaceId(restored.workspaces?.[0]?.id ?? null) + }) + .catch((error) => { if (active) showToast(errorMessage(error)) }) + .finally(() => { if (active) setAuthLoading(false) }) + return () => { active = false } + }, [showToast]) + + useEffect(() => { + if (!session?.user?.id || !inviteToken) return undefined + const attemptKey = `${session.user.id}:${inviteToken}` + if (invitationAttemptRef.current === attemptKey) return undefined + invitationAttemptRef.current = attemptKey + let active = true + setInviteAccepting(true) + api('/workspaces/invitations/accept', { method: 'POST', body: { token: inviteToken } }) + .then(({ workspace }) => { + if (!active) return + setSession((current) => current ? { + ...current, + workspaces: [...(current.workspaces ?? []).filter((item) => item.id !== workspace.id), workspace], + } : current) + setWorkspaceId(workspace.id) + setPage('team') + setInviteToken(null) + clearInvitationFromLocation() + showToast(`已加入 ${workspace.name}`) + }) + .catch((error) => { + if (!active) return + showToast(errorMessage(error)) + if (error instanceof ApiError && [400, 404, 409].includes(error.status)) { + setInviteToken(null) + clearInvitationFromLocation() + } + }) + .finally(() => { if (active) setInviteAccepting(false) }) + return () => { active = false } + }, [inviteToken, session?.user?.id, showToast]) + + const loadNotifications = useCallback(async (nextWorkspaceId) => { + if (!nextWorkspaceId) return { items: [], unreadCount: 0 } + const result = await api(`/workspaces/${nextWorkspaceId}/notifications?limit=50`) + setNotifications(result.items) + setNotificationUnreadCount(result.unreadCount) + return result + }, []) + + const loadWorkspaceData = useCallback(async (nextWorkspaceId) => { + if (!nextWorkspaceId) return + const [workspaceRows, projectRows, jobRows, reviewRows, memberRows, invitationRows, billingSummary, readiness, notificationResult, channelSummary] = await Promise.all([ + api('/workspaces'), + api(`/workspaces/${nextWorkspaceId}/projects`), + api(`/workspaces/${nextWorkspaceId}/jobs`), + api(`/workspaces/${nextWorkspaceId}/reviews`), + api(`/workspaces/${nextWorkspaceId}/members`), + api(`/workspaces/${nextWorkspaceId}/invitations`).catch((error) => { + if (error instanceof ApiError && error.status === 403) return [] + throw error + }), + api(`/workspaces/${nextWorkspaceId}/billing/summary`).catch((error) => { + if (error instanceof ApiError && error.status === 403) return null + throw error + }), + api(`/workspaces/${nextWorkspaceId}/service-readiness`), + api(`/workspaces/${nextWorkspaceId}/notifications?limit=50`), + api(`/workspaces/${nextWorkspaceId}/publishing/channels`), + ]) + setSession((current) => current ? { ...current, workspaces: workspaceRows } : current) + setProjects(projectRows) + setJobs(jobRows) + setReviews(reviewRows) + setMembers(memberRows) + setInvitations(invitationRows) + setBilling(billingSummary) + setServiceReadiness(readiness) + setNotifications(notificationResult.items) + setNotificationUnreadCount(notificationResult.unreadCount) + setPublishingChannels(channelSummary) + setProjectId((current) => projectRows.some((item) => item.id === current) ? current : projectRows[0]?.id ?? null) + }, []) + + const refreshBilling = useCallback(async (nextWorkspaceId) => { + if (!nextWorkspaceId) return null + const summary = await api(`/workspaces/${nextWorkspaceId}/billing/summary`) + setBilling(summary) + return summary + }, []) + + const loadProjectData = useCallback(async (nextProjectId) => { + if (!nextProjectId || !workspaceId) return + const [projectRow, assetRows] = await Promise.all([ + api(`/projects/${nextProjectId}`), + api(`/projects/${nextProjectId}/assets`), + ]) + setProject(projectRow) + setEpisodeId((current) => projectRow.episodes.some((item) => item.id === current) ? current : projectRow.episodes[0]?.id ?? null) + const mapped = await Promise.all(assetRows.map(async ({ asset, currentVersion }) => { + let imageUrl = fallbackAssetImage(asset) + let hasStoredMedia = false + if (currentVersion?.storageKey?.startsWith(`${workspaceId}/`)) { + try { + const media = await api(`/assets/${asset.id}/versions/${currentVersion.id}/url`) + if (media.url) { + imageUrl = media.url + hasStoredMedia = true + } + } catch { + imageUrl = fallbackAssetImage(asset) + } + } + return { + id: asset.id, + name: asset.name, + type: assetTypeLabels[asset.type] ?? asset.type, + rawType: asset.type, + img: imageUrl, + tag: asset.tags?.[0] ?? '未分类', + tags: asset.tags ?? [], + meta: asset.description || '暂无描述', + description: asset.description, + metadata: asset.metadata ?? {}, + status: assetStatusLabels[asset.status] ?? asset.status, + rawStatus: asset.status, + version: currentVersion ? `v${String(currentVersion.version).padStart(2, '0')}` : '暂无版本', + currentVersionId: currentVersion?.id ?? null, + currentVersion: currentVersion ?? null, + hasStoredMedia, + isPlaceholder: !hasStoredMedia, + } + })) + setAssets(mapped.filter((item) => item.rawType !== 'SHOT')) + setAssetVersionMedia(Object.fromEntries(mapped.filter((item) => item.currentVersionId && item.hasStoredMedia).map((item) => [item.currentVersionId, item.img]))) + }, [workspaceId]) + + const loadEpisodeData = useCallback(async (nextEpisodeId) => { + if (!nextEpisodeId) return + const [episodeRow, shotRows, renderRows, deliveryRows, productionRunRows] = await Promise.all([ + api(`/episodes/${nextEpisodeId}`), + api(`/episodes/${nextEpisodeId}/shots`), + api(`/episodes/${nextEpisodeId}/renders`), + api(`/episodes/${nextEpisodeId}/deliveries`), + api(`/episodes/${nextEpisodeId}/production-runs`), + ]) + const mediaRows = await Promise.all(shotRows.map(async ({ shot }) => { + try { + return await api(`/shots/${shot.id}/media`) + } catch { + return { imageUrl: null, videoUrl: null, audioUrl: null } + } + })) + setEpisode(episodeRow) + setStudioShots(shotRows.map(({ shot, currentVersion }, index) => { + const imageUrl = mediaRows[index]?.imageUrl ?? null + const videoUrl = mediaRows[index]?.videoUrl ?? null + const fallbackImage = currentVersion?.imageAssetVersionId + ? assetVersionMedia[currentVersion.imageAssetVersionId] ?? fallbackShots[index % fallbackShots.length].img + : fallbackShots[index % fallbackShots.length].img + return { + id: shot.id, + label: String(shot.shotNumber).padStart(2, '0'), + title: shot.title, + scene: shot.title, + duration: `${(shot.durationMs / 1000).toFixed(1)}s`, + durationMs: shot.durationMs, + sceneNumber: shot.sceneNumber, + type: shot.shotType, + img: imageUrl ?? fallbackImage, + copy: shot.description, + status: shotStatusLabels[shot.status] ?? shot.status, + cameraMotion: shot.cameraMotion, + subtitleText: currentVersion?.subtitleText ?? '', + videoUrl, + audioUrl: mediaRows[index]?.audioUrl ?? null, + hasImageMedia: Boolean(imageUrl), + hasVisualMedia: Boolean(imageUrl || videoUrl), + isPlaceholderPreview: !imageUrl, + characterAssetIds: shot.characterAssetIds ?? [], + sceneAssetId: shot.sceneAssetId ?? null, + sortOrder: shot.sortOrder, + currentVersion, + } + })) + setRenders(renderRows) + setDeliveries(deliveryRows) + setProductionRuns(productionRunRows) + }, [assetVersionMedia]) + + const realtimeDataRef = useRef(null) + realtimeDataRef.current = { projectId, episodeId, loadProjectData, loadEpisodeData } + + useEffect(() => { + if (!workspaceId) return + setDataLoading(true) + loadWorkspaceData(workspaceId).catch((error) => showToast(errorMessage(error))).finally(() => setDataLoading(false)) + }, [workspaceId, loadWorkspaceData, showToast]) + + useEffect(() => { + const outcome = billingReturnFromLocation() + if (!workspaceId || !outcome) return undefined + const attemptKey = `${workspaceId}:${outcome}` + if (billingReturnAttemptRef.current === attemptKey) return undefined + billingReturnAttemptRef.current = attemptKey + clearBillingReturnFromLocation() + setBillingOpen(true) + showToast(outcome === 'success' ? '支付已完成,正在同步订阅' : outcome === 'cancel' ? '已取消订阅结账' : '订阅管理已返回') + + const delays = outcome === 'success' ? [0, 1500, 4000] : [0] + const timers = delays.map((delay) => window.setTimeout(() => { + refreshBilling(workspaceId).catch((error) => showToast(errorMessage(error))) + }, delay)) + return () => timers.forEach((timer) => window.clearTimeout(timer)) + }, [workspaceId, refreshBilling, showToast]) + + useEffect(() => { + const outcome = publishingReturnFromLocation() + if (!workspaceId || !outcome) return + const attemptKey = `${workspaceId}:${outcome}` + if (publishingReturnAttemptRef.current === attemptKey) return + publishingReturnAttemptRef.current = attemptKey + clearPublishingReturnFromLocation() + setPage('publish') + void loadWorkspaceData(workspaceId).catch((error) => showToast(errorMessage(error))) + showToast(outcome === 'connected' ? '渠道账号已连接' : '渠道账号授权失败') + }, [workspaceId, loadWorkspaceData, showToast]) + + useEffect(() => { + if (!projectId) { + setProject(null) + return + } + loadProjectData(projectId).catch((error) => showToast(errorMessage(error))) + }, [projectId, loadProjectData, showToast]) + + useEffect(() => { + if (!episodeId) { + setEpisode(null) + setStudioShots([]) + setRenders([]) + setDeliveries([]) + setProductionRuns([]) + return + } + loadEpisodeData(episodeId).catch((error) => showToast(errorMessage(error))) + }, [episodeId, loadEpisodeData, showToast]) + + useEffect(() => { + if (!workspaceId || !jobs.some((job) => ['QUEUED', 'RUNNING'].includes(job.status))) return undefined + const timer = window.setInterval(async () => { + try { + const nextJobs = await api(`/workspaces/${workspaceId}/jobs`) + setJobs(nextJobs) + const current = realtimeDataRef.current + if (current?.projectId) await current.loadProjectData(current.projectId) + if (current?.episodeId) await current.loadEpisodeData(current.episodeId) + } catch (error) { + showToast(errorMessage(error)) + } + }, 3500) + return () => window.clearInterval(timer) + }, [workspaceId, projectId, episodeId, jobs, loadProjectData, loadEpisodeData, showToast]) + + useEffect(() => { + if (!workspaceId || !session?.user?.id) return undefined + let refreshTimer = null + let disposed = false + const refreshRealtimeData = async () => { + try { + const [nextJobs, nextReviews] = await Promise.all([ + api(`/workspaces/${workspaceId}/jobs`), + api(`/workspaces/${workspaceId}/reviews`), + ]) + if (disposed) return + setJobs(nextJobs) + setReviews(nextReviews) + if (projectId) await loadProjectData(projectId) + if (episodeId) await loadEpisodeData(episodeId) + } catch (error) { + if (!disposed) showToast(errorMessage(error)) + } + } + const refreshSoon = () => { + if (refreshTimer !== null) window.clearTimeout(refreshTimer) + refreshTimer = window.setTimeout(() => { + refreshTimer = null + void refreshRealtimeData() + }, 180) + } + const unsubscribe = subscribeRealtime(`/workspaces/${workspaceId}/events`, { + onStatus: (status) => { if (!disposed) setRealtimeStatus(status) }, + onEvent: (event) => { + if (disposed || event?.workspaceId !== workspaceId) return + if (event.type === 'realtime.ready') { + void loadNotifications(workspaceId) + refreshSoon() + return + } + if (event.type?.startsWith('notification.')) void loadNotifications(workspaceId) + if (['job.updated', 'review.updated', 'delivery.updated', 'publishing.channel.updated'].includes(event.type)) refreshSoon() + }, + }) + return () => { + disposed = true + if (refreshTimer !== null) window.clearTimeout(refreshTimer) + unsubscribe() + } + }, [workspaceId, session?.user?.id, loadNotifications, showToast]) + + useEffect(() => { + const onKey = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + setCommandOpen((value) => !value) + } + if (event.key === 'Escape') { + setCommandOpen(false) + setGeneratorOpen(false) + setGeneratorTarget(null) + setProjectOpen(false) + setProjectMenuOpen(false) + setVersionTarget(null) + setBillingOpen(false) + setSecurityOpen(false) + setProjectSettingsOpen(false) + setEpisodeSettingsOpen(false) + setNotificationOpen(false) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + const navigate = (nextPage) => { + setPage(nextPage) + setMobileNav(false) + setNotificationOpen(false) + window.scrollTo({ top: 0, behavior: 'smooth' }) + } + + const openSecurity = () => { + setSecurityOpen(true) + setMobileNav(false) + } + + const openNotification = async (notification) => { + if (!notification.readAt && notification.persistent) { + const readAt = new Date().toISOString() + setNotifications((current) => current.map((item) => item.id === notification.id ? { ...item, readAt } : item)) + setNotificationUnreadCount((current) => Math.max(0, current - 1)) + void api(`/notifications/${notification.id}`, { method: 'PATCH', body: { read: true } }) + .catch((error) => { + void loadNotifications(workspaceId).catch(() => undefined) + showToast(errorMessage(error)) + }) + } + navigate(notification.page) + } + + const readAllNotifications = async () => { + if (!workspaceId || notificationUnreadCount === 0) return + const readAt = new Date().toISOString() + setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? readAt }))) + setNotificationUnreadCount(0) + try { + await api(`/workspaces/${workspaceId}/notifications/read-all`, { method: 'POST' }) + } catch (error) { + await loadNotifications(workspaceId).catch(() => undefined) + showToast(errorMessage(error)) + } + } + + const selectWorkspace = (nextWorkspaceId) => { + if (!nextWorkspaceId || nextWorkspaceId === workspaceId) { + setProjectMenuOpen(false) + return + } + setWorkspaceId(nextWorkspaceId) + setProjectId(null) + setProject(null) + setEpisodeId(null) + setEpisode(null) + setAssets([]) + setStudioShots([]) + setRenders([]) + setDeliveries([]) + setNotifications([]) + setNotificationUnreadCount(0) + setRealtimeStatus('connecting') + setInvitations([]) + setProjectMenuOpen(false) + } + + const refreshCurrentData = async () => { + if (workspaceId) await loadWorkspaceData(workspaceId) + if (projectId) await loadProjectData(projectId) + if (episodeId) await loadEpisodeData(episodeId) + } + + const refreshServiceReadiness = async () => { + if (!workspaceId) return + const readiness = await api(`/workspaces/${workspaceId}/service-readiness`) + setServiceReadiness(readiness) + } + + const verifyServiceProvider = async (providerId) => { + if (!workspaceId) return null + const readiness = await api(`/workspaces/${workspaceId}/service-readiness/providers/${providerId}/verify`, { method: 'POST' }) + setServiceReadiness(readiness) + return readiness.ai?.providers?.find((provider) => provider.id === providerId) ?? null + } + + const requireAiWorkflow = (workflowId) => { + const workflow = findAiCapability(serviceReadiness, workflowId) + if (!workflow || workflow.status === 'CONFIGURED') return + setPage('services') + const missing = workflow.missingRequirements?.join('、') || '服务端模型配置' + throw new Error(`${workflow.label}尚未配置:${missing}`) + } + + const createProject = async (input) => { + const created = await api(`/workspaces/${workspaceId}/projects`, { method: 'POST', body: input }) + await loadWorkspaceData(workspaceId) + setProjectId(created.id) + setProjectOpen(false) + showToast('项目已创建并保存') + } + + const updateProjectSettings = async (input) => { + if (!projectId) return + await api(`/projects/${projectId}`, { method: 'PATCH', body: input }) + await Promise.all([loadWorkspaceData(workspaceId), loadProjectData(projectId)]) + setProjectSettingsOpen(false) + showToast('项目资料已保存') + } + + const archiveProject = async () => { + if (!projectId) return + await api(`/projects/${projectId}`, { method: 'DELETE' }) + setProjectSettingsOpen(false) + setProjectId(null) + setProject(null) + setEpisodeId(null) + setEpisode(null) + await loadWorkspaceData(workspaceId) + showToast('项目已归档,历史制作数据仍然保留') + } + + const updateEpisodeSettings = async (input) => { + if (!episodeId) return + await api(`/episodes/${episodeId}`, { method: 'PATCH', body: input }) + await Promise.all([loadProjectData(projectId), loadEpisodeData(episodeId)]) + setEpisodeSettingsOpen(false) + showToast('分集资料已保存') + } + + const openAssetGenerator = (target = null) => { + setGeneratorTarget(target) + setGeneratorOpen(true) + } + + const closeAssetGenerator = () => { + setGeneratorOpen(false) + setGeneratorTarget(null) + } + + const generateAsset = async (input) => { + requireAiWorkflow(input.provider === 'agnes' ? 'image-agnes' : input.provider === 'replicate' ? 'image-replicate' : input.provider === 'generic' ? 'image-generic' : 'image-openai') + let assetId = input.assetId + if (!assetId) { + const created = await api(`/projects/${projectId}/assets`, { + method: 'POST', + body: { type: assetTypeValues[input.type], name: input.name, description: input.prompt, tags: ['AI 生成'] }, + }) + assetId = created.id + } + await api(`/assets/${assetId}`, { method: 'PATCH', body: { status: 'GENERATING', description: input.prompt } }) + try { + const queued = await api(`/projects/${projectId}/jobs`, { + method: 'POST', + body: { + type: 'ASSET_GENERATE', + input: { + assetId, + prompt: input.prompt, + width: input.width, + height: input.height, + referenceAssetVersionIds: input.referenceAssetVersionIds, + provider: input.provider, + params: { consistencyStrength: input.consistencyStrength }, + }, + }, + }) + closeAssetGenerator() + setPage('assets') + await refreshCurrentData() + showToast(`${input.assetId ? '一致性变体' : '资产生成'}任务已入队 · ${queued.job.id.slice(0, 8)}`) + } catch (error) { + await api(`/assets/${assetId}`, { method: 'PATCH', body: { status: 'DRAFT' } }).catch(() => {}) + throw error + } + } + + const copyAssetReference = async (asset) => { + if (!asset.currentVersionId) throw new Error('当前资产尚无可引用版本') + if (!navigator.clipboard?.writeText) throw new Error('当前浏览器不支持复制到剪贴板') + await navigator.clipboard.writeText(`@${asset.name} [asset:${asset.id}/version:${asset.currentVersionId}]`) + showToast(`已复制 ${asset.name} 的版本引用`) + } + + const importAsset = async ({ file, type, name, description, tags }) => { + const form = new FormData() + form.append('file', file) + const uploaded = await api(`/projects/${projectId}/uploads`, { method: 'POST', body: form }) + const created = await api(`/projects/${projectId}/assets`, { + method: 'POST', + body: { type, name, description, tags }, + }) + await api(`/assets/${created.id}/versions`, { + method: 'POST', + body: { storageKey: uploaded.key, mimeType: uploaded.contentType, byteSize: uploaded.byteSize, generationParams: { source: 'upload', filename: uploaded.filename } }, + }) + await loadProjectData(projectId) + showToast(`${name} 已作为${assetTypeLabels[type]}导入资产库`) + } + + const updateAsset = async (assetId, input, successMessage = '资产信息已保存') => { + await api(`/assets/${assetId}`, { method: 'PATCH', body: input }) + await loadProjectData(projectId) + showToast(successMessage) + } + + const saveCharacterConsistencyProfile = async (assetId, input) => { + await api(`/assets/${assetId}/consistency-profile`, { method: 'PUT', body: input }) + await loadProjectData(projectId) + showToast(input.identityLocked ? '角色身份档案已锁定' : '角色身份档案已保存') + } + + const generateScript = async (instruction) => { + requireAiWorkflow('script') + const provider = preferredWorkflowProvider(serviceReadiness, 'script', 'openai') + const queued = await api(`/projects/${projectId}/jobs`, { + method: 'POST', + body: { type: 'SCRIPT_GENERATE', episodeId, input: { instruction, includeExistingScript: true, provider } }, + }) + await loadWorkspaceData(workspaceId) + showToast(`剧本任务已入队 · ${queued.job.id.slice(0, 8)}`) + } + + const generateStoryboard = async (replaceExisting = false) => { + requireAiWorkflow('storyboard') + const provider = preferredWorkflowProvider(serviceReadiness, 'storyboard', 'openai') + const queued = await api(`/projects/${projectId}/jobs`, { + method: 'POST', + body: { + type: 'STORYBOARD_GENERATE', + episodeId, + input: { instruction: '根据当前已保存剧本生成完整、可制作的漫剧分镜', replaceExisting, provider }, + }, + }) + await loadWorkspaceData(workspaceId) + setPage('storyboard') + showToast(`智能分镜任务已入队 · ${queued.job.id.slice(0, 8)}`) + } + + const saveScript = async (plainText) => { + const saved = await api(`/episodes/${episodeId}/scripts`, { + method: 'POST', + body: { title: episode.title, content: { source: 'editor', plainText }, plainText, changeSummary: '工作台手动保存' }, + }) + await loadEpisodeData(episodeId) + showToast(`剧本 v${saved.version} 已保存`) + } + + const generateShot = async (shot, provider = 'openai') => { + requireAiWorkflow(provider === 'agnes' ? 'image-agnes' : provider === 'replicate' ? 'image-replicate' : provider === 'generic' ? 'image-generic' : 'image-openai') + const queued = await api(`/projects/${projectId}/jobs`, { + method: 'POST', + body: { type: 'ASSET_GENERATE', episodeId, input: { shotId: shot.id, prompt: shot.copy, width: 1536, height: 864, provider } }, + }) + await loadWorkspaceData(workspaceId) + showToast(`镜头 ${shot.label} 生成任务已入队 · ${queued.job.id.slice(0, 8)}`) + } + + const createShot = async () => { + const nextNumber = studioShots.reduce((largest, shot) => Math.max(largest, Number(shot.label)), 0) + 1 + await api(`/episodes/${episodeId}/shots`, { + method: 'POST', + body: { + shotNumber: nextNumber, + sceneNumber: studioShots.at(-1)?.sceneNumber ?? nextNumber, + title: `镜头 ${String(nextNumber).padStart(2, '0')}`, + description: '补充该镜头的画面描述后即可生成。', + shotType: '中景', + cameraMotion: '静止', + durationMs: 3000, + characterAssetIds: [], + sortOrder: nextNumber * 100, + }, + }) + await loadEpisodeData(episodeId) + showToast(`镜头 ${String(nextNumber).padStart(2, '0')} 已创建`) + } + + const updateShot = async (shotId, input) => { + await api(`/shots/${shotId}`, { method: 'PATCH', body: input }) + await loadEpisodeData(episodeId) + showToast('镜头属性已保存') + } + + const enqueueShotProduction = async (type, shot, options = {}) => { + requireAiWorkflow(type === 'TTS_GENERATE' ? 'tts' : type === 'VIDEO_GENERATE' ? 'video' : 'lipsync') + const provider = type === 'TTS_GENERATE' + ? 'openai' + : type === 'VIDEO_GENERATE' + ? preferredVideoProvider(serviceReadiness) + : 'replicate' + const input = type === 'TTS_GENERATE' + ? { shotId: shot.id, text: options.text || shot.subtitleText || shot.copy, voice: options.voice || 'alloy', speed: options.speed || 1, provider } + : type === 'VIDEO_GENERATE' + ? { shotId: shot.id, prompt: shot.copy, durationSeconds: shot.durationMs / 1000, provider } + : { shotId: shot.id, provider } + const queued = await api(`/projects/${projectId}/jobs`, { + method: 'POST', + body: { type, episodeId, input }, + }) + await loadWorkspaceData(workspaceId) + showToast(`${jobTypeLabels[type]}已入队 · ${queued.job.id.slice(0, 8)}`) + } + + const saveShotVersion = async (shotId, input) => { + await api(`/shots/${shotId}/versions`, { method: 'POST', body: input }) + await loadEpisodeData(episodeId) + } + + const saveShotProduction = async (shotId, input) => { + const result = await api(`/shots/${shotId}/production`, { method: 'PATCH', body: input }) + await loadEpisodeData(episodeId) + return result + } + + const importShotMedia = async (file, shot) => { + if (!file.type.startsWith('audio/') && !file.type.startsWith('video/')) throw new Error('制作中心仅支持导入音频或视频文件') + const form = new FormData() + form.append('file', file) + const uploaded = await api(`/projects/${projectId}/uploads`, { method: 'POST', body: form }) + await saveShotVersion(shot.id, { + ...(file.type.startsWith('audio/') ? { audioStorageKey: uploaded.key } : { videoStorageKey: uploaded.key }), + generationParams: { source: 'production-upload', filename: uploaded.filename, mimeType: uploaded.contentType }, + }) + showToast(`${file.name} 已绑定到镜头 ${shot.label}`) + } + + const reorderShot = async (shotId, direction) => { + const ordered = [...studioShots].sort((a, b) => a.sortOrder - b.sortOrder) + const index = ordered.findIndex((shot) => shot.id === shotId) + const target = index + direction + if (index < 0 || target < 0 || target >= ordered.length) return + ;[ordered[index], ordered[target]] = [ordered[target], ordered[index]] + await api(`/episodes/${episodeId}/shots/reorder`, { + method: 'PATCH', + body: { shots: ordered.map((shot, itemIndex) => ({ id: shot.id, sortOrder: (itemIndex + 1) * 100 })) }, + }) + await loadEpisodeData(episodeId) + showToast('镜头顺序已保存') + } + + const duplicateShot = async (shot) => { + const nextNumber = studioShots.reduce((largest, item) => Math.max(largest, Number(item.label)), 0) + 1 + const created = await api(`/episodes/${episodeId}/shots`, { + method: 'POST', + body: { + shotNumber: nextNumber, + sceneNumber: shot.sceneNumber, + title: `${shot.title} · 副本`, + description: shot.copy, + shotType: shot.type, + cameraMotion: shot.cameraMotion, + durationMs: shot.durationMs, + characterAssetIds: shot.characterAssetIds, + sceneAssetId: shot.sceneAssetId, + sortOrder: shot.sortOrder + 1, + }, + }) + if (shot.currentVersion) { + await api(`/shots/${created.id}/versions`, { + method: 'POST', + body: { + imageAssetVersionId: shot.currentVersion.imageAssetVersionId, + videoStorageKey: shot.currentVersion.videoStorageKey, + audioStorageKey: shot.currentVersion.audioStorageKey, + subtitleText: shot.subtitleText, + generationParams: { source: 'duplicate', sourceShotId: shot.id }, + }, + }) + } + await loadEpisodeData(episodeId) + showToast(`镜头 ${shot.label} 已复制`) + } + + const deleteShot = async (shot) => { + await api(`/shots/${shot.id}`, { method: 'DELETE' }) + await loadEpisodeData(episodeId) + showToast(`镜头 ${shot.label} 已删除`) + } + + const createEpisode = async () => { + const nextNumber = (project?.episodes ?? []).reduce((largest, item) => Math.max(largest, item.episodeNumber), 0) + 1 + const created = await api(`/projects/${projectId}/episodes`, { + method: 'POST', + body: { episodeNumber: nextNumber, title: `第 ${String(nextNumber).padStart(2, '0')} 集`, synopsis: '', targetDurationSeconds: 180 }, + }) + await loadProjectData(projectId) + setEpisodeId(created.id) + showToast(`第 ${String(nextNumber).padStart(2, '0')} 集已创建`) + } + + const createRender = async (settings) => { + const queued = await api(`/episodes/${episodeId}/renders`, { method: 'POST', body: { settings } }) + await refreshCurrentData() + showToast(`成片 v${queued.render.version} 已进入渲染队列`) + } + + const startProductionRun = async (settings) => { + const run = await api(`/episodes/${episodeId}/production-runs`, { method: 'POST', body: settings }) + await Promise.all([loadWorkspaceData(workspaceId), loadEpisodeData(episodeId)]) + showToast(`整集生产已启动 · ${run.totalJobs} 个任务`) + return run + } + + const cancelProductionRun = async (run) => { + await api(`/production-runs/${run.id}`, { method: 'DELETE' }) + await Promise.all([loadWorkspaceData(workspaceId), loadEpisodeData(episodeId)]) + showToast('整集生产已取消') + } + + const approveRender = async (render) => { + const review = await api(`/workspaces/${workspaceId}/reviews`, { method: 'POST', body: { targetType: 'RENDER', targetId: render.id, summary: `${episode?.title ?? '成片'} · v${render.version} 终审` } }) + await loadWorkspaceData(workspaceId) + setPage('reviews') + showToast(`成片 v${render.version} 已提交审核 · ${review.id.slice(0, 8)}`) + } + + const publishRender = async (render, options = {}) => { + await api(`/renders/${render.id}/deliveries`, { + method: 'POST', + body: { + platform: options.platform ?? 'DOWNLOAD', + ...(options.channelAccountId ? { channelAccountId: options.channelAccountId } : {}), + ...(options.destination ? { destination: options.destination } : {}), + ...(options.scheduledAt ? { scheduledAt: options.scheduledAt } : {}), + }, + }) + await refreshCurrentData() + const platformLabel = publishingPlatformLabels[options.platform ?? 'DOWNLOAD'] ?? options.platform + showToast(options.scheduledAt + ? `${platformLabel}已安排在 ${new Date(options.scheduledAt).toLocaleString('zh-CN')} 执行` + : `成片 v${render.version} 已进入${platformLabel}队列`) + } + + const connectPublishingChannel = async (platform) => { + const popup = window.open('', '_blank') + try { + const result = await api(`/workspaces/${workspaceId}/publishing/oauth/start`, { + method: 'POST', + body: { platform, returnPath: '/?publishing=connected' }, + }) + if (!popup) throw new Error('浏览器阻止了渠道授权窗口') + popup.location = result.authorizationUrl + } catch (error) { + if (popup) popup.close() + throw error + } + } + + const revokePublishingChannel = async (account) => { + await api(`/publishing/channels/${account.id}`, { method: 'DELETE' }) + await loadWorkspaceData(workspaceId) + showToast(`${publishingPlatformLabels[account.platform] ?? account.platform}账号已解除连接`) + } + + const cancelDelivery = async (delivery) => { + await api(`/deliveries/${delivery.id}`, { method: 'DELETE' }) + await refreshCurrentData() + showToast(`成片 v${delivery.render.version} 的交付已取消`) + } + + const retryDelivery = async (delivery) => { + const result = await api(`/deliveries/${delivery.id}/retry`, { method: 'POST' }) + await refreshCurrentData() + showToast(`成片 v${delivery.render.version} 已重新进入交付队列 · ${result.job.id.slice(0, 8)}`) + } + + const activateVersion = async (target, versionId) => { + const endpoint = target.kind === 'asset' + ? `/assets/${target.id}/versions/${versionId}/activate` + : target.kind === 'script' + ? `/episodes/${target.id}/scripts/${versionId}/activate` + : `/shots/${target.id}/versions/${versionId}/activate` + await api(endpoint, { method: 'POST' }) + await refreshCurrentData() + showToast('当前版本已切换') + } + + const requestVersionReview = async (target, version) => { + const targetType = target.kind === 'asset' ? 'ASSET_VERSION' : target.kind === 'script' ? 'SCRIPT_VERSION' : 'SHOT_VERSION' + const created = await api(`/workspaces/${workspaceId}/reviews`, { + method: 'POST', + body: { targetType, targetId: version.id, summary: `${target.title} · v${version.version}` }, + }) + await loadWorkspaceData(workspaceId) + showToast(`v${version.version} 已提交审核 · ${created.id.slice(0, 8)}`) + } + + const cancelJob = async (job) => { + await api(`/jobs/${job.id}`, { method: 'DELETE' }) + await loadWorkspaceData(workspaceId) + showToast(`${jobTypeLabels[job.type] ?? job.type}任务已取消`) + } + + const retryJob = async (job) => { + const result = await api(`/jobs/${job.id}/retry`, { method: 'POST' }) + await loadWorkspaceData(workspaceId) + showToast(`重试任务已入队 · ${result.job.id.slice(0, 8)}`) + } + + const startBillingCheckout = async (plan) => { + const result = await api(`/workspaces/${workspaceId}/billing/checkout`, { + method: 'POST', + body: { plan, requestId: crypto.randomUUID() }, + }) + window.location.assign(result.url) + } + + const openBillingPortal = async () => { + const result = await api(`/workspaces/${workspaceId}/billing/portal`, { method: 'POST' }) + window.location.assign(result.url) + } + + const endSession = async () => { + try { + await logout() + } finally { + setSession(null) + setWorkspaceId(null) + setProjects([]) + setProjectId(null) + setEpisodeId(null) + setInvitations([]) + setNotifications([]) + setNotificationUnreadCount(0) + setRealtimeStatus('closed') + setServiceReadiness(null) + setSecurityOpen(false) + } + } + + const pageTitle = navItems.find((item) => item.id === page)?.label || '创作总览' + + if (authLoading) return + if (!session?.user) return <> { setSession(nextSession); setWorkspaceId(nextSession.workspaces?.[0]?.id ?? null) }} />{toast && } + + const sessionWorkspaces = session.workspaces ?? [] + const workspace = sessionWorkspaces.find((item) => item.id === workspaceId) ?? sessionWorkspaces[0] + const canEditContent = ['OWNER', 'ADMIN', 'EDITOR'].includes(workspace?.role) + + return ( +
+ setMobileNav(false)} workspace={workspace} user={session.user} jobs={jobs} reviews={reviews} billing={billing} onBilling={() => setBillingOpen(true)} onSecurity={openSecurity} /> +
+ setMobileNav(true)} + onSearch={() => setCommandOpen(true)} + onToast={showToast} + project={project} + episode={episode} + user={session.user} + projects={projects} + workspaces={sessionWorkspaces} + workspace={workspace} + onSecurity={openSecurity} + projectMenuOpen={projectMenuOpen} + onProjectMenu={() => { setProjectMenuOpen((value) => !value); setNotificationOpen(false) }} + onSelectProject={(nextProjectId) => { setProjectId(nextProjectId); setProjectMenuOpen(false) }} + onSelectWorkspace={selectWorkspace} + onProjectSettings={() => { setProjectMenuOpen(false); setProjectSettingsOpen(true) }} + onEpisodeSettings={() => { setProjectMenuOpen(false); setEpisodeSettingsOpen(true) }} + canEditContent={canEditContent} + jobs={jobs} + reviews={reviews} + deliveries={deliveries} + notifications={notifications} + notificationUnreadCount={notificationUnreadCount} + realtimeStatus={realtimeStatus} + notificationOpen={notificationOpen} + onNotifications={() => { setNotificationOpen((value) => !value); setProjectMenuOpen(false) }} + onOpenNotification={openNotification} + onReadAllNotifications={readAllNotifications} + onNavigate={navigate} + /> +
+ {page === 'overview' && ( + setProjectOpen(true)} + onGenerate={() => openAssetGenerator()} + onProjectSettings={() => setProjectSettingsOpen(true)} + canEditContent={canEditContent} + /> + )} + {page === 'assets' && openAssetGenerator()} onVariant={openAssetGenerator} onCopyReference={copyAssetReference} onImport={importAsset} onUpdate={updateAsset} onSaveConsistencyProfile={saveCharacterConsistencyProfile} onToast={showToast} onOpenVersions={(asset) => setVersionTarget({ kind: 'asset', id: asset.id, title: asset.name })} />} + {page === 'script' && setEpisodeSettingsOpen(true)} canEditContent={canEditContent} onNavigate={navigate} onToast={showToast} onGenerate={generateScript} onGenerateStoryboard={generateStoryboard} onSave={saveScript} onOpenVersions={() => episode && setVersionTarget({ kind: 'script', id: episode.id, title: episode.title })} />} + {page === 'storyboard' && setVersionTarget({ kind: 'shot', id: shot.id, title: `SH_${shot.label} · ${shot.title}` })} />} + {page === 'production' && setVersionTarget({ kind: 'shot', id: shot.id, title: `SH_${shot.label} · ${shot.title}` })} />} + {page === 'jobs' && loadWorkspaceData(workspaceId)} onCancel={cancelJob} onRetry={retryJob} onToast={showToast} />} + {page === 'services' && } + {page === 'reviews' && } + {page === 'publish' && } + {page === 'team' && loadWorkspaceData(workspaceId)} onToast={showToast} />} + {page === 'audit' && ['OWNER', 'ADMIN'].includes(workspace?.role) && } + {(dataLoading || inviteAccepting) &&
{inviteAccepting ? '正在接受工作室邀请' : '同步项目数据'}
} +
+
+ {generatorOpen && } + {projectOpen && setProjectOpen(false)} onCreate={createProject} />} + {commandOpen && setCommandOpen(false)} onNavigate={navigate} />} + {versionTarget && setVersionTarget(null)} onActivate={activateVersion} onRequestReview={requestVersionReview} onToast={showToast} />} + {billingOpen && billing && setBillingOpen(false)} onCheckout={startBillingCheckout} onPortal={openBillingPortal} />} + {securityOpen && setSecurityOpen(false)} onLogout={endSession} onPasswordChanged={endSession} onToast={showToast} />} + {projectSettingsOpen && project && setProjectSettingsOpen(false)} onSave={updateProjectSettings} onArchive={archiveProject} onToast={showToast} />} + {episodeSettingsOpen && episode && setEpisodeSettingsOpen(false)} onSave={updateEpisodeSettings} onToast={showToast} />} + {toast && } +
+ ) +} + +function LoadingScreen() { + return
正在恢复工作台
+} + +function AuthScreen({ hasInvitation, onAuthenticated }) { + const actionParams = useMemo(() => new URLSearchParams(window.location.search), []) + const verificationToken = actionParams.get('verify_email') + const resetToken = actionParams.get('reset_password') + const [mode, setMode] = useState(verificationToken ? 'verify' : resetToken ? 'reset' : 'login') + const [email, setEmail] = useState(hasInvitation ? '' : 'studio@frameflow.local') + const [password, setPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [displayName, setDisplayName] = useState('') + const [workspaceName, setWorkspaceName] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [debugUrl, setDebugUrl] = useState('') + const verificationStarted = useRef(false) + + const clearActionParams = useCallback(() => { + const url = new URL(window.location.href) + url.searchParams.delete('verify_email') + url.searchParams.delete('reset_password') + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) + }, []) + + useEffect(() => { + if (mode !== 'verify' || !verificationToken || verificationStarted.current) return + verificationStarted.current = true + setSubmitting(true) + api('/auth/verify-email/confirm', { method: 'POST', body: { token: verificationToken }, retryAuth: false }) + .then(() => { + clearActionParams() + setNotice('邮箱验证完成,现在可以登录工作台。') + setMode('notice') + }) + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setSubmitting(false)) + }, [clearActionParams, mode, verificationToken]) + + const changeMode = (nextMode) => { + setMode(nextMode) + setError('') + setNotice('') + setDebugUrl('') + setPassword('') + setConfirmPassword('') + if (nextMode === 'register' && email === 'studio@frameflow.local') setEmail('') + } + + const submit = async (event) => { + event.preventDefault() + if (mode === 'reset' && password !== confirmPassword) { + setError('两次输入的新密码不一致') + return + } + setSubmitting(true) + setError('') + try { + if (mode === 'forgot') { + const result = await api('/auth/password/forgot', { method: 'POST', body: { email }, retryAuth: false }) + setNotice('如果该邮箱已注册,密码重置邮件已经发送。') + setDebugUrl(result.debugResetUrl ?? '') + setMode('notice') + return + } + if (mode === 'reset') { + await api('/auth/password/reset', { method: 'POST', body: { token: resetToken, newPassword: password }, retryAuth: false }) + clearActionParams() + setNotice('密码已重置,所有旧设备会话均已退出。') + setMode('notice') + return + } + const nextSession = mode === 'login' ? await login(email, password) : await register({ email, password, displayName, workspaceName }) + if (nextSession.verificationRequired) { + setNotice(nextSession.emailSent ? '验证邮件已经发送,请完成邮箱验证后登录。' : '账号已创建,请通过验证链接完成邮箱验证。') + setDebugUrl(nextSession.debugVerificationUrl ?? '') + setMode('notice') + return + } + onAuthenticated(nextSession) + } catch (requestError) { + if (requestError instanceof ApiError && requestError.code === 'EMAIL_VERIFICATION_REQUIRED') { + try { + const result = await api('/auth/verify-email/request', { method: 'POST', body: { email }, retryAuth: false }) + setNotice('邮箱尚未验证,新的验证邮件已经发送。') + setDebugUrl(result.debugVerificationUrl ?? '') + setMode('notice') + return + } catch (verificationError) { + setError(errorMessage(verificationError)) + return + } + } + setError(errorMessage(requestError)) + } finally { + setSubmitting(false) + } + } + + const brand =
FRAMEFLOWAI 漫剧工场
+ + if (mode === 'verify') { + return ( +
{brand}

正在验证邮箱

验证链接仅可使用一次,请稍候。

{submitting &&
正在确认验证信息
}{error &&
{error}
}{error && }
+ ) + } + + if (mode === 'notice') { + return ( +
{brand}

操作已提交

{notice}

{debugUrl && 打开本地调试链接}
+ ) + } + + return ( +
+
+ {brand} + {['login', 'register'].includes(mode) &&
} +

{mode === 'login' ? '登录工作台' : mode === 'register' ? '创建创作空间' : mode === 'forgot' ? '找回密码' : '设置新密码'}

{mode === 'login' ? '继续管理剧本、资产、镜头与成片交付。' : mode === 'register' ? '建立你的工作室账号并开始第一个漫剧项目。' : mode === 'forgot' ? '输入注册邮箱,我们会发送一次性重置链接。' : '新密码设置成功后,所有旧设备会话都会退出。'}

+ {hasInvitation && ['login', 'register'].includes(mode) &&
工作室邀请待接受请使用收到邀请的邮箱登录或创建账号,成功后将自动加入工作室。
} + {mode === 'register' && <>} + {mode !== 'reset' && } + {mode !== 'forgot' && } + {mode === 'reset' && } + {mode === 'login' && } + {error &&
{error}
} + + {['forgot', 'reset'].includes(mode) && } +
+
+ ) +} + +function Sidebar({ current, onNavigate, open, onClose, workspace, user, jobs, reviews, billing, onBilling, onSecurity }) { + const activeJobs = jobs.filter((job) => ['QUEUED', 'RUNNING'].includes(job.status)).length + const openReviews = reviews.filter((review) => review.status === 'OPEN').length + const initial = (user?.displayName || user?.email || 'F').slice(0, 1).toUpperCase() + const creditLimit = billing?.monthlyCreditLimit ?? workspace?.monthlyCreditLimit ?? 0 + const remainingCredits = billing?.remainingCredits ?? creditLimit + const availablePercent = creditLimit > 0 ? Math.max(0, Math.min(100, remainingCredits / creditLimit * 100)) : 0 + return ( + <> +
+ + + ) +} + +function Topbar({ pageTitle, onMenu, onSearch, onToast, project, episode, user, projects, workspaces, workspace, projectMenuOpen, onProjectMenu, onSelectProject, onSelectWorkspace, onProjectSettings, onEpisodeSettings, onSecurity, canEditContent, jobs, reviews, notifications: persistentNotifications, notificationUnreadCount, realtimeStatus, notificationOpen, onNotifications, onOpenNotification, onReadAllNotifications }) { + const initial = (user?.displayName || user?.email || 'F').slice(0, 1).toUpperCase() + const notifications = useMemo(() => { + const items = persistentNotifications.map((notification) => ({ + id: notification.id, + title: notification.title, + detail: notification.body, + time: notification.createdAt, + page: notification.page, + tone: notification.tone === 'ERROR' ? 'red' : notification.tone === 'SUCCESS' ? 'green' : notification.tone === 'INFO' ? 'blue' : 'orange', + icon: notification.kind.startsWith('REVIEW_') ? ClipboardCheck : notification.tone === 'ERROR' ? AlertCircle : notification.tone === 'SUCCESS' ? CheckCircle2 : Bell, + readAt: notification.readAt, + resourceId: notification.resourceId, + persistent: true, + })) + const persistedResources = new Set(items.map((item) => item.resourceId).filter(Boolean)) + jobs.filter((job) => ['FAILED', 'QUEUED', 'RUNNING'].includes(job.status) && !persistedResources.has(job.id)).forEach((job) => { + items.push({ + id: `job-${job.id}`, + title: `${jobTypeLabels[job.type] ?? job.type}${job.status === 'FAILED' ? '失败' : job.status === 'RUNNING' ? '执行中' : '排队中'}`, + detail: job.status === 'FAILED' ? job.errorMessage || '任务执行失败,可前往任务中心重试。' : `任务进度 ${job.progress ?? 0}%`, + time: job.updatedAt ?? job.createdAt, + page: 'jobs', + tone: job.status === 'FAILED' ? 'red' : 'orange', + icon: job.status === 'FAILED' ? AlertCircle : CircleDashed, + persistent: false, + }) + }) + reviews.filter((review) => review.status === 'OPEN' && !persistedResources.has(review.id)).forEach((review) => items.push({ + id: `review-${review.id}`, + title: '待处理审核', + detail: review.summary || `${reviewTargetLabels[review.targetType] ?? '版本'}等待审核`, + time: review.updatedAt ?? review.createdAt, + page: 'reviews', + tone: 'orange', + icon: ClipboardCheck, + persistent: false, + })) + return items.sort((left, right) => new Date(right.time).getTime() - new Date(left.time).getTime()).slice(0, 50) + }, [jobs, reviews, persistentNotifications]) + const liveAttentionCount = notifications.filter((item) => !item.persistent && item.tone !== 'green').length + const attentionCount = notificationUnreadCount + liveAttentionCount + return ( +
+
+ +
{pageTitle}
+
+ + {projectMenuOpen &&
+ {workspaces.length > 1 && <>
工作室
{workspaces.map((item) => )}
项目
} + {projects.map((item) => )} + {projects.length === 0 &&
当前工作室还没有项目
} + {project &&
} +
} +
+
+
+ + +
+ + {notificationOpen &&
通知中心{notificationUnreadCount > 0 ? `${notificationUnreadCount} 条未读` : liveAttentionCount > 0 ? `${liveAttentionCount} 项进行中` : '暂无未读通知'}
{notificationUnreadCount > 0 && }
{notifications.map((item) => { const Icon = item.icon; return })}{notifications.length === 0 &&
所有任务和审核都已处理
}
} +
+ +
+
+ ) +} + +function OverviewPage({ assets, project, episode, shots, jobs, reviews, renders, user, onNavigate, onNewProject, onGenerate, onProjectSettings, canEditContent }) { + const completedShots = shots.filter((shot) => shot.status === '完成').length + const activeJobs = jobs.filter((job) => ['QUEUED', 'RUNNING'].includes(job.status)).length + const openReviews = reviews.filter((review) => review.status === 'OPEN').length + const episodeNumber = episode?.episodeNumber ?? 0 + const duration = formatDuration(episode?.targetDurationSeconds ? episode.targetDurationSeconds * 1000 : 0) + const readyAssets = assets.filter((asset) => asset.hasStoredMedia).length + const versionedShots = shots.filter((shot) => shot.currentVersion).length + const productionReady = shots.filter((shot) => shot.currentVersion?.videoStorageKey || shot.videoUrl).length + const health = projectHealthSnapshot({ assets, project, episode, shots, jobs, reviews, renders }) + const previewShot = shots.find((shot) => shot.hasImageMedia && shot.img) + const previewAsset = assets.find((asset) => asset.rawType === 'SCENE' && asset.hasStoredMedia) + ?? assets.find((asset) => !['VOICE', 'MUSIC'].includes(asset.rawType) && asset.hasStoredMedia) + const previewImage = previewShot?.img ?? previewAsset?.img ?? '/assets/scene-rooftop.jpg' + const previewLabel = previewShot?.title ?? previewAsset?.name ?? '示例制作画面' + const previewIsPlaceholder = !previewShot && !previewAsset + const dynamicStages = [ + { label: '剧本', detail: episode?.currentScript ? `v${episode.currentScript.version}` : '待创建', state: episode?.currentScript ? 'done' : 'active' }, + { label: '资产', detail: `${readyAssets} / ${assets.length}`, state: assets.length === 0 ? 'next' : readyAssets === assets.length ? 'done' : 'active' }, + { label: '分镜', detail: `${versionedShots} / ${shots.length}`, state: shots.length === 0 ? 'next' : versionedShots === shots.length ? 'done' : 'active' }, + { label: '制作', detail: `${productionReady} / ${shots.length}`, state: shots.length > 0 && productionReady === shots.length ? 'done' : productionReady > 0 ? 'active' : 'next' }, + { label: '成片', detail: renders.length > 0 ? `v${renders[0].version}` : '待开始', state: renders.some((render) => render.status === 'APPROVED') ? 'done' : renders.length ? 'active' : 'next' }, + ] + const stageProgress = Math.round(dynamicStages.filter((stage) => stage.state === 'done').length / dynamicStages.length * 100) + return ( +
+
+
+

{new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }).format(new Date())}

+

{user?.displayName ?? '创作者'},继续把故事变成画面。

+

{project && episode ? `《${project.name}》第 ${String(episodeNumber).padStart(2, '0')} 集正在制作,当前有 ${openReviews} 项待审核。` : '创建项目和分集后即可开始完整制作流程。'}

+
+
+ + {project && } + +
+
+ +
+
+ 第 {String(episodeNumber).padStart(2, '0')} 集制作进度 + {stageProgress}% +
+
+ {dynamicStages.map((stage, index) => ( +
+
{stage.state === 'done' ? : index + 1}
+
{stage.label}{stage.detail}
+ {index < dynamicStages.length - 1 && } +
+ ))} +
+
+ +
+
+ onNavigate('production')} /> +
+
+ {previewLabel} + {previewIsPlaceholder && 示例预览} +
+ EP {String(episodeNumber).padStart(2, '0')} + + {formatDuration(shots.reduce((total, shot) => total + shot.durationMs, 0))} / {duration} +
+
+
+
+
第 {String(episodeNumber).padStart(2, '0')} 集

{episode?.title ?? '尚未创建分集'}

+ {episode?.status ?? '未开始'} +
+

{episode?.synopsis || '为本集补充故事梗概后,AI 编剧将以此作为生成约束。'}

+
+
预计时长{duration}
+
镜头数量{shots.length}
+
已完成{completedShots}
+
待审核{openReviews}
+
+ +
+
+
+ + +
+ +
+ onNavigate('assets')} /> +
+ {assets.slice(0, 6).map((asset) => )} +
+
+ +
+
+
项目健康度 {health.score}{health.suggestion}
+ +
+
+ ) +} + +function AssetsPage({ assets, workspaceRole, onGenerate, onVariant, onCopyReference, onImport, onUpdate, onSaveConsistencyProfile, onToast, onOpenVersions }) { + const [tab, setTab] = useState('全部') + const [view, setView] = useState('grid') + const [query, setQuery] = useState('') + const [selected, setSelected] = useState(null) + const [statusFilter, setStatusFilter] = useState('ACTIVE') + const [tagFilter, setTagFilter] = useState('ALL') + const [editTarget, setEditTarget] = useState(null) + const [profileTarget, setProfileTarget] = useState(null) + const [importFile, setImportFile] = useState(null) + const uploadInput = useRef(null) + const tabs = ['全部', '人物', '场景', '道具', '音色', '音乐'] + const canEdit = ['OWNER', 'ADMIN', 'EDITOR'].includes(workspaceRole) + const tags = useMemo(() => [...new Set(assets.flatMap((asset) => asset.tags))].sort((a, b) => a.localeCompare(b, 'zh-CN')), [assets]) + const normalizedQuery = query.trim().toLocaleLowerCase('zh-CN') + const matchesCommonFilters = (asset) => { + const matchesStatus = statusFilter === 'ALL' + || statusFilter === 'ACTIVE' && asset.rawStatus !== 'ARCHIVED' + || asset.rawStatus === statusFilter + const matchesTag = tagFilter === 'ALL' || asset.tags.includes(tagFilter) + const searchable = `${asset.name} ${asset.description} ${asset.tags.join(' ')}`.toLocaleLowerCase('zh-CN') + return matchesStatus && matchesTag && (!normalizedQuery || searchable.includes(normalizedQuery)) + } + const filtered = assets.filter((asset) => matchesCommonFilters(asset) && (tab === '全部' || asset.type === tab)) + const selectedAsset = assets.find((asset) => asset.id === selected) + const activeFilterCount = (statusFilter !== 'ACTIVE' ? 1 : 0) + (tagFilter !== 'ALL' ? 1 : 0) + + useEffect(() => { + if (selected && !filtered.some((asset) => asset.id === selected)) setSelected(null) + }, [filtered, selected]) + + const archiveAsset = async (asset) => { + const restoring = asset.rawStatus === 'ARCHIVED' + if (!restoring && !window.confirm(`确认归档资产「${asset.name}」?历史版本会保留。`)) return + try { + await onUpdate(asset.id, { status: restoring ? 'DRAFT' : 'ARCHIVED' }, restoring ? '资产已恢复为草稿' : '资产已归档') + setSelected(null) + setEditTarget(null) + } catch (error) { + onToast(errorMessage(error)) + } + } + + return ( +
+ { const file = event.target.files?.[0]; if (file) setImportFile(file); event.target.value = '' }} />} + /> +
+
+ {tabs.map((item) => )} +
+
+ +
{activeFilterCount > 0 && }
+
+
+
+ + {filtered.length ? ( +
+ {filtered.map((asset) => setSelected(selected === asset.id ? null : asset.id)} onMore={canEdit ? () => setEditTarget(asset) : null} />)} +
+ ) : ( +

没有找到匹配资产

{statusFilter === 'ARCHIVED' ? '归档区目前为空。' : '调整搜索或筛选条件,或创建一项新资产。'}

{statusFilter === 'ARCHIVED' ? : }
+ )} + + {selected && ( +
+ 已选择 1 项资产 +
{selectedAsset?.rawType === 'CHARACTER' && }
+
+ )} + {importFile && setImportFile(null)} onImport={onImport} onToast={onToast} />} + {editTarget && setEditTarget(null)} onSave={onUpdate} onArchive={archiveAsset} onToast={onToast} />} + {profileTarget && setProfileTarget(null)} onSave={onSaveConsistencyProfile} onToast={onToast} />} +
+ ) +} + +function ScriptPage({ project, episodes, episode, onSelectEpisode, onAddEpisode, onEditEpisode, canEditContent, onNavigate, onToast, onGenerate, onGenerateStoryboard, onSave, onOpenVersions }) { + const [assistantOpen, setAssistantOpen] = useState(true) + const [saving, setSaving] = useState(false) + const [assistantBusy, setAssistantBusy] = useState(false) + const [assistantPrompt, setAssistantPrompt] = useState('') + const [scriptText, setScriptText] = useState('') + const [history, setHistory] = useState({ entries: [''], index: 0 }) + const editorRef = useRef(null) + + useEffect(() => { + const nextText = episode?.currentScript?.plainText ?? '' + setScriptText(nextText) + setHistory({ entries: [nextText], index: 0 }) + }, [episode?.id, episode?.currentScript?.id]) + + const savedText = episode?.currentScript?.plainText ?? '' + const dirty = scriptText !== savedText + const canUndo = history.index > 0 + const canRedo = history.index < history.entries.length - 1 + const analysis = useMemo(() => { + const lines = scriptText.split('\n').map((line) => line.trim()).filter(Boolean) + const characters = scriptText.replace(/\s/g, '').length + const sceneCount = lines.filter((line) => /^(第?\s*\d+\s*场|场景|内景|外景|int\.|ext\.)/i.test(line)).length + const dialogueCount = lines.filter((line) => /^[^::\n]{1,16}[::]/.test(line)).length + const actionCount = lines.filter((line) => /【[^】]+】|([^)]+)/.test(line)).length + const targetCharacters = Math.max(400, (episode?.targetDurationSeconds ?? 180) * 4) + const lengthRatio = characters / targetCharacters + const score = characters === 0 ? 0 : Math.min(100, + Math.round(Math.min(45, lengthRatio * 45) + + Math.min(25, sceneCount * 5) + + Math.min(15, dialogueCount * 1.5) + + Math.min(15, actionCount * 2))) + let suggestion = { + tag: '一致性建议', + text: `当前包含 ${sceneCount} 个场景、${dialogueCount} 段对白和 ${actionCount} 处动作提示,可继续检查人物动机与称谓。`, + instruction: '检查并修复角色动机、称谓、场景时间和行为的一致性,不改变核心情节', + } + if (characters === 0) { + suggestion = { tag: '起稿建议', text: '当前分集还没有正文,可先根据梗概生成分场剧本。', instruction: '根据本集梗概生成包含场景、动作与对白的完整分场剧本' } + } else if (sceneCount < 2) { + suggestion = { tag: '结构建议', text: '场景标记较少,建议明确拆分内外景、时间和地点,方便后续自动分镜。', instruction: '将当前剧本整理为清晰的分场结构,为每场补充内外景、时间和地点标记' } + } else if (dialogueCount === 0) { + suggestion = { tag: '对白建议', text: '尚未识别到“角色:对白”结构,建议补充可直接用于配音的对白。', instruction: '为主要场景补充角色对白,并统一使用“角色:对白”格式' } + } else if (lengthRatio < 0.65) { + suggestion = { tag: '篇幅建议', text: `当前约 ${characters} 字,低于本集目标时长对应篇幅,可补充关键行动与转折。`, instruction: '在不注水的前提下补充关键行动、情绪递进和场景转折,使篇幅匹配目标时长' } + } else if (lengthRatio > 1.35) { + suggestion = { tag: '节奏建议', text: `当前约 ${characters} 字,超过目标时长对应篇幅,建议压缩重复信息与冗余对白。`, instruction: '压缩重复信息和冗余对白,保留核心冲突,使剧本匹配目标时长' } + } else if (actionCount < Math.max(1, Math.floor(sceneCount / 2))) { + suggestion = { tag: '画面建议', text: '动作与环境提示偏少,建议增加可直接转化为镜头的视觉行动。', instruction: '为对白密集场景补充简洁的动作和环境变化,增强可视化表达' } + } + return { score, characters, sceneCount, dialogueCount, actionCount, suggestion } + }, [scriptText, episode?.targetDurationSeconds]) + + const updateScriptText = (nextText) => { + setScriptText(nextText) + setHistory((current) => { + if (current.entries[current.index] === nextText) return current + let entries = [...current.entries.slice(0, current.index + 1), nextText] + if (entries.length > 100) entries = entries.slice(entries.length - 100) + return { entries, index: entries.length - 1 } + }) + } + + const moveHistory = (direction) => { + const nextIndex = Math.max(0, Math.min(history.entries.length - 1, history.index + direction)) + if (nextIndex === history.index) return + setHistory((current) => ({ ...current, index: nextIndex })) + setScriptText(history.entries[nextIndex]) + window.requestAnimationFrame(() => editorRef.current?.focus()) + } + + const formatSelection = (prefix, suffix, placeholder) => { + const editor = editorRef.current + if (!editor) return + const start = editor.selectionStart + const end = editor.selectionEnd + const selectedText = scriptText.slice(start, end) || placeholder + const replacement = `${prefix}${selectedText}${suffix}` + updateScriptText(`${scriptText.slice(0, start)}${replacement}${scriptText.slice(end)}`) + window.requestAnimationFrame(() => { + editor.focus() + editor.setSelectionRange(start + prefix.length, start + prefix.length + selectedText.length) + }) + } + + const insertScriptBlock = (prefix, placeholder) => { + const editor = editorRef.current + if (!editor) return + const start = editor.selectionStart + const end = editor.selectionEnd + const selectedText = scriptText.slice(start, end) || placeholder + const leadingBreak = start > 0 && scriptText[start - 1] !== '\n' ? '\n' : '' + const trailingBreak = end < scriptText.length && scriptText[end] !== '\n' ? '\n' : '' + const replacement = `${leadingBreak}${prefix}${selectedText}${trailingBreak}` + updateScriptText(`${scriptText.slice(0, start)}${replacement}${scriptText.slice(end)}`) + const selectionStart = start + leadingBreak.length + prefix.length + window.requestAnimationFrame(() => { + editor.focus() + editor.setSelectionRange(selectionStart, selectionStart + selectedText.length) + }) + } + + const runAiAction = async (instruction, message) => { + if (!instruction.trim() || assistantBusy) return false + setAssistantBusy(true) + try { + if (dirty) await onSave(scriptText) + await onGenerate(instruction) + onToast(message) + return true + } catch (error) { + onToast(errorMessage(error)) + return false + } finally { + setAssistantBusy(false) + } + } + + const submitAssistant = async (event) => { + event?.preventDefault() + const instruction = assistantPrompt.trim() + if (!instruction) return + if (await runAiAction(instruction, '自定义剧本修改任务已提交')) setAssistantPrompt('') + } + + const saveCurrent = async () => { + if (!dirty || saving) return + setSaving(true) + try { + await onSave(scriptText) + } catch (error) { + onToast(errorMessage(error)) + } finally { + setSaving(false) + } + } + + const onEditorKeyDown = (event) => { + if (!(event.metaKey || event.ctrlKey)) return + const key = event.key.toLowerCase() + if (key === 's') { + event.preventDefault() + saveCurrent() + } else if (key === 'z') { + event.preventDefault() + moveHistory(event.shiftKey ? 1 : -1) + } else if (key === 'y') { + event.preventDefault() + moveHistory(1) + } else if (key === 'b') { + event.preventDefault() + formatSelection('**', '**', '重点文本') + } else if (key === 'i') { + event.preventDefault() + formatSelection('*', '*', '强调文本') + } + } + + const createStoryboard = async () => { + const hasShots = (episode?.shots?.length ?? 0) > 0 + if (hasShots && !window.confirm('当前分集已有分镜。仅草稿分镜可以被重新生成,确认继续吗?')) return + setSaving(true) + try { + if (scriptText !== (episode?.currentScript?.plainText ?? '')) await onSave(scriptText) + await onGenerateStoryboard(hasShots) + } catch (error) { + onToast(errorMessage(error)) + } finally { + setSaving(false) + } + } + + return ( +
+ } /> +
+ + +
+
+
+
{saving ? <>处理中 : dirty ? <>有未保存修改 : <>已保存 · v{episode?.currentScript?.version ?? 0}}
+
+
+
{project?.name ?? '未选择项目'}EP.{String(episode?.episodeNumber ?? 0).padStart(2, '0')}约 {formatDuration((episode?.targetDurationSeconds ?? 0) * 1000)}
+

{episode?.title ?? '尚未创建分集'}

+