feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# FlatRender V2 — Service Contracts
|
||||
|
||||
This directory defines all inter-service contracts. Services are loosely
|
||||
coupled — they communicate via REST APIs (synchronous) and RabbitMQ
|
||||
events (asynchronous). The browser connects to the Gateway over HTTPS +
|
||||
WebSocket.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
contracts/
|
||||
├── common/ # Reusable OpenAPI components (Error, Pagination, ...)
|
||||
│ └── types.yaml
|
||||
├── events/ # RabbitMQ event schemas (JSON Schema)
|
||||
│ ├── README.md # Event catalog with routing keys
|
||||
│ ├── render.yaml
|
||||
│ ├── node.yaml
|
||||
│ ├── identity.yaml
|
||||
│ ├── file.yaml
|
||||
│ ├── tenant.yaml
|
||||
│ └── notification.yaml
|
||||
├── websocket/ # WebSocket message protocol
|
||||
│ └── render-progress.md
|
||||
└── rest/ # Per-service OpenAPI 3.0 specs
|
||||
├── identity.openapi.yaml # internal
|
||||
├── content.openapi.yaml # internal
|
||||
├── studio.openapi.yaml # internal
|
||||
├── render.openapi.yaml # internal
|
||||
├── file.openapi.yaml # internal
|
||||
├── notification.openapi.yaml # internal
|
||||
├── node-agent.openapi.yaml # called by render orchestrator
|
||||
├── gateway-public.openapi.yaml # what frontend uses
|
||||
└── reseller-api.openapi.yaml # B2B API (api-key auth)
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
| Surface | Auth | Carries |
|
||||
|----------------------|---------------------------------|------------------------|
|
||||
| Gateway public API | JWT (Bearer) | user_id, tenant_id |
|
||||
| Reseller API | API key (X-API-Key + signature) | tenant_id, scopes |
|
||||
| Internal service API | Service token (mTLS in prod) | service_name |
|
||||
| Node Agent API | Shared secret (HMAC) | orchestrator_signature |
|
||||
| WebSocket | JWT in query param `?token=...` | user_id, job_id |
|
||||
|
||||
## Versioning
|
||||
|
||||
- REST: URL prefix `/v1/...`
|
||||
- Events: routing key suffix `.v1`
|
||||
- WebSocket: protocol negotiation header
|
||||
|
||||
## Conventions
|
||||
|
||||
- All IDs are UUID v4
|
||||
- All timestamps ISO 8601 with timezone (`2026-05-27T10:15:00Z`)
|
||||
- All money fields are `_minor` integers (rial / cent)
|
||||
- All durations are seconds (numeric)
|
||||
- All file sizes are bytes (integer)
|
||||
- Pagination: `?page=1&page_size=20` returning `PaginatedResponse<T>`
|
||||
- Errors: `{ "error": { "code", "message", "details", "trace_id" } }`
|
||||
- Tenant context: `X-Tenant-Id` header on internal calls (JWT carries it on public)
|
||||
@@ -0,0 +1,305 @@
|
||||
# =====================================================================
|
||||
# Common OpenAPI Components — referenced by every service spec via $ref
|
||||
# =====================================================================
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: FlatRender Common Types
|
||||
version: "1.0"
|
||||
|
||||
components:
|
||||
# -------------------------------------------------------------------
|
||||
# Errors
|
||||
# -------------------------------------------------------------------
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
required: [error]
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: NOT_FOUND
|
||||
message:
|
||||
type: string
|
||||
example: "Render job not found"
|
||||
details:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
field_errors:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
field: { type: string }
|
||||
message: { type: string }
|
||||
trace_id:
|
||||
type: string
|
||||
format: uuid
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Pagination
|
||||
# ---------------------------------------------------------------
|
||||
PaginationMeta:
|
||||
type: object
|
||||
required: [page, page_size, total, has_more]
|
||||
properties:
|
||||
page: { type: integer, minimum: 1, example: 1 }
|
||||
page_size: { type: integer, minimum: 1, maximum: 200, example: 20 }
|
||||
total: { type: integer, minimum: 0, example: 137 }
|
||||
has_more: { type: boolean }
|
||||
next_cursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional cursor for keyset pagination
|
||||
|
||||
PaginatedEnvelope:
|
||||
type: object
|
||||
required: [data, meta]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
meta:
|
||||
$ref: '#/components/schemas/PaginationMeta'
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Money
|
||||
# ---------------------------------------------------------------
|
||||
Money:
|
||||
type: object
|
||||
required: [amount_minor, currency]
|
||||
properties:
|
||||
amount_minor:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Amount in minor units (rial, cent, etc.)
|
||||
example: 1500000
|
||||
currency:
|
||||
type: string
|
||||
enum: [IRR, USD, EUR]
|
||||
example: IRR
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Audit
|
||||
# ---------------------------------------------------------------
|
||||
Timestamps:
|
||||
type: object
|
||||
properties:
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
deleted_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Localized text (Persian + English)
|
||||
# ---------------------------------------------------------------
|
||||
LocalizedString:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
example:
|
||||
fa: "عنوان"
|
||||
en: "Title"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Tenant
|
||||
# ---------------------------------------------------------------
|
||||
TenantContext:
|
||||
type: object
|
||||
required: [tenant_id, slug]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
slug: { type: string }
|
||||
kind:
|
||||
type: string
|
||||
enum: [Internal, Reseller, Enterprise]
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# User reference (lightweight)
|
||||
# ---------------------------------------------------------------
|
||||
UserRef:
|
||||
type: object
|
||||
required: [id]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
full_name: { type: string, nullable: true }
|
||||
avatar_url: { type: string, nullable: true }
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# File reference
|
||||
# ---------------------------------------------------------------
|
||||
FileRef:
|
||||
type: object
|
||||
required: [id, url, file_type]
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
file_type:
|
||||
type: string
|
||||
enum: [Video, Image, Audio, Voiceover, Document, Other]
|
||||
mime_type: { type: string, nullable: true }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
duration_sec: { type: number, nullable: true }
|
||||
width: { type: integer, nullable: true }
|
||||
height: { type: integer, nullable: true }
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Enums (reused everywhere)
|
||||
# ---------------------------------------------------------------
|
||||
ChooseMode:
|
||||
type: string
|
||||
enum: [FIX, FLEXIBLE, MockUp, MusicVisualizer, VoiceOver]
|
||||
|
||||
Resolution:
|
||||
type: string
|
||||
enum: [HD, FullHD, TwoK, FourK]
|
||||
|
||||
SceneType:
|
||||
type: string
|
||||
enum: [Normal, Config, DesignStart, DesignEnd]
|
||||
|
||||
JustifyKind:
|
||||
type: string
|
||||
enum: [LEFT_JUSTIFY, CENTER_JUSTIFY, RIGHT_JUSTIFY, FULL_JUSTIFY]
|
||||
|
||||
RenderStep:
|
||||
type: string
|
||||
enum:
|
||||
- Queued
|
||||
- Preparing
|
||||
- TemplateCache
|
||||
- JsxGen
|
||||
- Music
|
||||
- Rendering
|
||||
- Validating
|
||||
- Repairing
|
||||
- Optimisation
|
||||
- Video
|
||||
- Mixing
|
||||
- Final
|
||||
- Uploading
|
||||
- Done
|
||||
- Failed
|
||||
- Cancelled
|
||||
|
||||
RenderPriorityQueue:
|
||||
type: string
|
||||
enum: [snapshot, vip, paid, preview, mockup, voiceover]
|
||||
|
||||
PriceType:
|
||||
type: string
|
||||
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
|
||||
|
||||
RenderQuality:
|
||||
type: string
|
||||
enum: [Low, Medium, High, Full, Lossless]
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Common headers
|
||||
# -------------------------------------------------------------------
|
||||
parameters:
|
||||
TenantIdHeader:
|
||||
name: X-Tenant-Id
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string, format: uuid }
|
||||
description: Tenant context (carried by JWT on public APIs)
|
||||
|
||||
RequestIdHeader:
|
||||
name: X-Request-Id
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string, format: uuid }
|
||||
description: Optional client-supplied correlation ID
|
||||
|
||||
PageParam:
|
||||
name: page
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, default: 1 }
|
||||
|
||||
PageSizeParam:
|
||||
name: page_size
|
||||
in: query
|
||||
schema: { type: integer, minimum: 1, maximum: 200, default: 20 }
|
||||
|
||||
CursorParam:
|
||||
name: cursor
|
||||
in: query
|
||||
schema: { type: string }
|
||||
description: Keyset pagination cursor
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Standard responses
|
||||
# -------------------------------------------------------------------
|
||||
responses:
|
||||
BadRequest:
|
||||
description: Validation error or malformed request
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
Unauthorized:
|
||||
description: Missing or invalid auth
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
Forbidden:
|
||||
description: Authenticated but not allowed
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
NotFound:
|
||||
description: Resource not found
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
Conflict:
|
||||
description: Resource conflict
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
TooManyRequests:
|
||||
description: Rate limited
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
InternalError:
|
||||
description: Unexpected server error
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Security schemes
|
||||
# -------------------------------------------------------------------
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: JWT with claims sub, tenant_id, scope
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Key
|
||||
description: B2B reseller API key (fr_live_..., fr_test_...)
|
||||
ApiKeySignature:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-API-Signature
|
||||
description: HMAC-SHA256 of (timestamp + body) using key secret
|
||||
ServiceToken:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Inter-service short-lived JWT issued by control plane
|
||||
NodeHmac:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Node-Signature
|
||||
description: HMAC for orchestrator <-> node-agent comms
|
||||
@@ -0,0 +1,129 @@
|
||||
# RabbitMQ Event Catalog
|
||||
|
||||
All async communication between services uses RabbitMQ. Events follow
|
||||
strict naming: `{domain}.{entity}.{verb}.v{n}` (past tense).
|
||||
|
||||
## Exchanges
|
||||
|
||||
| Exchange | Type | Purpose |
|
||||
|---------------------------|----------|--------------------------------------|
|
||||
| `flatrender.events` | topic | Domain events (fan-out by routing) |
|
||||
| `flatrender.render` | direct | Render job dispatch (per queue) |
|
||||
| `flatrender.notify` | direct | Notification dispatch (per channel) |
|
||||
| `flatrender.dlq` | fanout | Dead-letter queue |
|
||||
|
||||
## Common envelope
|
||||
|
||||
Every message body has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "uuid",
|
||||
"event_type": "render.job.completed.v1",
|
||||
"event_time": "2026-05-27T10:15:00Z",
|
||||
"tenant_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"trace_id": "uuid",
|
||||
"correlation_id": "uuid",
|
||||
"producer": "render-orchestrator",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Headers (AMQP)
|
||||
|
||||
| Header | Required | Notes |
|
||||
|---------------------|----------|------------------------------------------------|
|
||||
| `content-type` | yes | `application/json` |
|
||||
| `content-encoding` | yes | `utf-8` |
|
||||
| `x-event-type` | yes | Same as `event_type` (for routing convenience) |
|
||||
| `x-tenant-id` | yes | For tenant-aware consumers |
|
||||
| `x-trace-id` | yes | Distributed tracing |
|
||||
| `x-retry-count` | optional | Incremented on requeue |
|
||||
| `x-max-retries` | optional | Default 3 |
|
||||
|
||||
## Routing keys (topic exchange `flatrender.events`)
|
||||
|
||||
```
|
||||
identity.user.registered.v1
|
||||
identity.user.email_verified.v1
|
||||
identity.user.banned.v1
|
||||
identity.tenant.created.v1
|
||||
identity.tenant.suspended.v1
|
||||
identity.plan.activated.v1
|
||||
identity.plan.expired.v1
|
||||
identity.payment.succeeded.v1
|
||||
identity.payment.failed.v1
|
||||
identity.payment.refunded.v1
|
||||
identity.api_key.created.v1
|
||||
identity.api_key.revoked.v1
|
||||
|
||||
content.template.published.v1
|
||||
content.template.unpublished.v1
|
||||
content.font.installed.v1
|
||||
content.svg_preview.generated.v1
|
||||
|
||||
studio.project.saved.v1
|
||||
studio.project.deleted.v1
|
||||
|
||||
render.job.queued.v1
|
||||
render.job.started.v1
|
||||
render.job.progress.v1
|
||||
render.job.completed.v1
|
||||
render.job.failed.v1
|
||||
render.job.cancelled.v1
|
||||
render.snapshot.requested.v1
|
||||
render.snapshot.ready.v1
|
||||
|
||||
node.online.v1
|
||||
node.offline.v1
|
||||
node.crashed.v1
|
||||
node.heartbeat.v1
|
||||
node.cache.updated.v1
|
||||
|
||||
file.uploaded.v1
|
||||
file.processed.v1
|
||||
file.deleted.v1
|
||||
file.quota_warning.v1
|
||||
file.quota_exceeded.v1
|
||||
file.cleanup.scheduled.v1
|
||||
file.cleanup.executed.v1
|
||||
|
||||
notification.created.v1
|
||||
notification.delivered.v1
|
||||
notification.failed.v1
|
||||
|
||||
tenant.usage.recorded.v1
|
||||
tenant.webhook.fired.v1
|
||||
tenant.webhook.failed.v1
|
||||
```
|
||||
|
||||
## Render dispatch (direct exchange `flatrender.render`)
|
||||
|
||||
```
|
||||
render.queue.snapshot
|
||||
render.queue.vip
|
||||
render.queue.paid
|
||||
render.queue.preview
|
||||
render.queue.mockup
|
||||
render.queue.voiceover
|
||||
```
|
||||
|
||||
Each queue has priority `x-max-priority: 10`. Job priority encoded in
|
||||
message priority property.
|
||||
|
||||
## Notification dispatch (direct exchange `flatrender.notify`)
|
||||
|
||||
```
|
||||
notify.channel.push
|
||||
notify.channel.email
|
||||
notify.channel.sms
|
||||
notify.channel.telegram
|
||||
notify.channel.webhook
|
||||
```
|
||||
|
||||
## Dead-letter routing
|
||||
|
||||
Failed messages (after `x-max-retries` exhausted) are republished to
|
||||
`flatrender.dlq` with original routing key preserved in
|
||||
`x-original-routing-key` header.
|
||||
@@ -0,0 +1,61 @@
|
||||
# =====================================================================
|
||||
# Content Events — published by Content Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
content.template.published.v1:
|
||||
routing_key: content.template.published.v1
|
||||
description: A template (project_container) was published.
|
||||
payload:
|
||||
type: object
|
||||
required: [container_id, slug, primary_mode]
|
||||
properties:
|
||||
container_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
slug: { type: string }
|
||||
name: { type: string }
|
||||
primary_mode: { type: string }
|
||||
project_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
content.template.unpublished.v1:
|
||||
routing_key: content.template.unpublished.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [container_id, reason]
|
||||
properties:
|
||||
container_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
slug: { type: string }
|
||||
reason: { type: string }
|
||||
|
||||
content.font.installed.v1:
|
||||
routing_key: content.font.installed.v1
|
||||
description: Font registered as installed on render nodes.
|
||||
payload:
|
||||
type: object
|
||||
required: [font_id, name, system_name, node_ids]
|
||||
properties:
|
||||
font_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
system_name: { type: string }
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
content.svg_preview.generated.v1:
|
||||
routing_key: content.svg_preview.generated.v1
|
||||
description: AI service produced an SVG color preview for a scene/project.
|
||||
payload:
|
||||
type: object
|
||||
required: [svg_preview_id, svg_url, color_zones_count]
|
||||
properties:
|
||||
svg_preview_id: { type: string, format: uuid }
|
||||
project_id: { type: string, format: uuid, nullable: true }
|
||||
scene_id: { type: string, format: uuid, nullable: true }
|
||||
svg_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
color_zones_count: { type: integer }
|
||||
quality_score: { type: number, minimum: 0, maximum: 1 }
|
||||
generated_by_ai: { type: boolean }
|
||||
@@ -0,0 +1,114 @@
|
||||
# =====================================================================
|
||||
# File Events — published by File Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
file.uploaded.v1:
|
||||
routing_key: file.uploaded.v1
|
||||
description: A file upload has completed and is ready to use.
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, user_id, file_type, size_bytes, url]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
folder_id: { type: string, format: uuid, nullable: true }
|
||||
name: { type: string }
|
||||
file_type: { type: string, enum: [Video, Image, Audio, Voiceover, Document, Other] }
|
||||
mime_type: { type: string }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
duration_sec: { type: number, nullable: true }
|
||||
width: { type: integer, nullable: true }
|
||||
height: { type: integer, nullable: true }
|
||||
source:
|
||||
type: string
|
||||
enum: [upload, export, snapshot, voiceover_record, stock]
|
||||
|
||||
file.processed.v1:
|
||||
routing_key: file.processed.v1
|
||||
description: Post-upload processing (thumbnail, waveform, transcode) finished.
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, status]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Ready, Failed, Quarantined] }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
waveform_generated: { type: boolean }
|
||||
duration_sec: { type: number, nullable: true }
|
||||
error_message: { type: string, nullable: true }
|
||||
|
||||
file.deleted.v1:
|
||||
routing_key: file.deleted.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, user_id, size_bytes_freed]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
size_bytes_freed: { type: integer, format: int64 }
|
||||
deleted_by:
|
||||
type: string
|
||||
enum: [user, auto_cleanup, admin, quota_exceeded]
|
||||
|
||||
file.quota_warning.v1:
|
||||
routing_key: file.quota_warning.v1
|
||||
description: User passed 90% of storage quota.
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, used_bytes, quota_bytes, percent_used]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
used_bytes: { type: integer, format: int64 }
|
||||
quota_bytes: { type: integer, format: int64 }
|
||||
percent_used: { type: number }
|
||||
|
||||
file.quota_exceeded.v1:
|
||||
routing_key: file.quota_exceeded.v1
|
||||
description: User hit 100% storage quota — uploads blocked.
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, used_bytes, quota_bytes]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
used_bytes: { type: integer, format: int64 }
|
||||
quota_bytes: { type: integer, format: int64 }
|
||||
attempted_upload_size_bytes: { type: integer, format: int64, nullable: true }
|
||||
|
||||
file.cleanup.scheduled.v1:
|
||||
routing_key: file.cleanup.scheduled.v1
|
||||
description: An entity has been queued for automatic deletion.
|
||||
payload:
|
||||
type: object
|
||||
required: [cleanup_id, entity_type, entity_id, scheduled_delete_at]
|
||||
properties:
|
||||
cleanup_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
user_id: { type: string, format: uuid, nullable: true }
|
||||
entity_type:
|
||||
type: string
|
||||
enum: [Export, TempRenderFolder, OrphanedFile, UnusedUpload, SnapshotExpired]
|
||||
entity_id: { type: string, format: uuid }
|
||||
scheduled_delete_at: { type: string, format: date-time }
|
||||
notify_user_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
file.cleanup.executed.v1:
|
||||
routing_key: file.cleanup.executed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [cleanup_id, status, bytes_freed]
|
||||
properties:
|
||||
cleanup_id: { type: string, format: uuid }
|
||||
entity_type: { type: string }
|
||||
entity_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Done, Skipped, Failed] }
|
||||
bytes_freed: { type: integer, format: int64 }
|
||||
error_message: { type: string, nullable: true }
|
||||
@@ -0,0 +1,177 @@
|
||||
# =====================================================================
|
||||
# Identity Events — published by Identity Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
identity.user.registered.v1:
|
||||
routing_key: identity.user.registered.v1
|
||||
description: New user account created (any tenant).
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, tenant_id, register_mode]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
email: { type: string, nullable: true }
|
||||
phone_number: { type: string, nullable: true }
|
||||
full_name: { type: string, nullable: true }
|
||||
register_mode:
|
||||
type: string
|
||||
enum: [Email, Mobile, Google, Telegram, SSO, Reseller]
|
||||
affiliate_owner_id: { type: string, format: uuid, nullable: true }
|
||||
registered_with_mobile_app: { type: boolean }
|
||||
|
||||
identity.user.email_verified.v1:
|
||||
routing_key: identity.user.email_verified.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, email]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
email: { type: string }
|
||||
|
||||
identity.user.banned.v1:
|
||||
routing_key: identity.user.banned.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, banned_by, reason]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
banned_by: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
unblock_date: { type: string, format: date-time, nullable: true }
|
||||
|
||||
identity.tenant.created.v1:
|
||||
routing_key: identity.tenant.created.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, slug, kind]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
slug: { type: string }
|
||||
name: { type: string }
|
||||
kind: { type: string, enum: [Internal, Reseller, Enterprise] }
|
||||
contact_email: { type: string, nullable: true }
|
||||
|
||||
identity.tenant.suspended.v1:
|
||||
routing_key: identity.tenant.suspended.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, reason]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
suspended_at: { type: string, format: date-time }
|
||||
|
||||
identity.plan.activated.v1:
|
||||
routing_key: identity.plan.activated.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, user_plan_id, plan_code, expires_at]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_plan_id: { type: string, format: uuid }
|
||||
plan_id: { type: string, format: uuid }
|
||||
plan_code: { type: string }
|
||||
plan_name: { type: string }
|
||||
seconds_charge: { type: integer }
|
||||
storage_gb: { type: integer }
|
||||
parallel_renders: { type: integer }
|
||||
expires_at: { type: string, format: date-time }
|
||||
payment_id: { type: string, format: uuid, nullable: true }
|
||||
|
||||
identity.plan.expired.v1:
|
||||
routing_key: identity.plan.expired.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, user_plan_id]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_plan_id: { type: string, format: uuid }
|
||||
plan_code: { type: string }
|
||||
days_overdue: { type: integer }
|
||||
|
||||
identity.payment.succeeded.v1:
|
||||
routing_key: identity.payment.succeeded.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, amount_minor, gateway, action]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
gateway:
|
||||
type: string
|
||||
enum: [ZarinPal, IdPay, Bazaar, Stripe, Balance, Manual]
|
||||
action:
|
||||
type: string
|
||||
enum: [PlanPurchase, BalanceCharge, ProjectRender, UserProject, StorageUpgrade, Other]
|
||||
gateway_track_id: { type: string, nullable: true }
|
||||
plan_id: { type: string, format: uuid, nullable: true }
|
||||
affiliate_owner_id: { type: string, format: uuid, nullable: true }
|
||||
affiliate_profit_minor: { type: integer, format: int64 }
|
||||
|
||||
identity.payment.failed.v1:
|
||||
routing_key: identity.payment.failed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, gateway, failure_reason]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
gateway: { type: string }
|
||||
failure_reason: { type: string }
|
||||
action: { type: string }
|
||||
|
||||
identity.payment.refunded.v1:
|
||||
routing_key: identity.payment.refunded.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, refund_amount_minor, reason]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
refund_amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
reason: { type: string }
|
||||
refunded_to:
|
||||
type: string
|
||||
enum: [Balance, OriginalMethod, Plan]
|
||||
|
||||
identity.api_key.created.v1:
|
||||
routing_key: identity.api_key.created.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [api_key_id, tenant_id, name, environment, created_by_user_id]
|
||||
properties:
|
||||
api_key_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
environment: { type: string, enum: [Live, Test] }
|
||||
key_prefix: { type: string }
|
||||
last4: { type: string }
|
||||
scopes:
|
||||
type: array
|
||||
items: { type: string }
|
||||
created_by_user_id: { type: string, format: uuid }
|
||||
|
||||
identity.api_key.revoked.v1:
|
||||
routing_key: identity.api_key.revoked.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [api_key_id, tenant_id, revoked_by_user_id, reason]
|
||||
properties:
|
||||
api_key_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
revoked_by_user_id: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
@@ -0,0 +1,122 @@
|
||||
# =====================================================================
|
||||
# Node Events — published by Node Agent → Render Orchestrator
|
||||
# Routing: flatrender.events (topic) — key: node.*.v1
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.online.v1 — node agent starts up
|
||||
# -------------------------------------------------------------------
|
||||
node.online.v1:
|
||||
routing_key: node.online.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, region, node_agent_version, current_ae_version]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
node_ip: { type: string, format: ipv4 }
|
||||
region: { type: string }
|
||||
node_agent_version: { type: string }
|
||||
current_ae_version: { type: string }
|
||||
available_ae_versions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
ram_gb: { type: integer }
|
||||
cpu_cores: { type: integer }
|
||||
cache_used_gb: { type: integer }
|
||||
cached_template_md5s:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.offline.v1 — graceful shutdown OR detected missed heartbeats
|
||||
# -------------------------------------------------------------------
|
||||
node.offline.v1:
|
||||
routing_key: node.offline.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, reason]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
reason: { type: string, enum: [Shutdown, HeartbeatLost, Maintenance, Disabled] }
|
||||
last_heartbeat_at: { type: string, format: date-time }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.heartbeat.v1 — every 5s (NOT broadcast on topic exchange,
|
||||
# sent direct to orchestrator HTTP endpoint OR a dedicated stream)
|
||||
# Documented here for completeness.
|
||||
# -------------------------------------------------------------------
|
||||
node.heartbeat.v1:
|
||||
routing_key: node.heartbeat.v1
|
||||
transport: HTTP POST /v1/internal/nodes/{node_id}/heartbeat
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, status, recorded_at]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Ready, Busy, Crashed, Updating] }
|
||||
recorded_at: { type: string, format: date-time }
|
||||
cpu_pct: { type: integer, minimum: 0, maximum: 100 }
|
||||
ram_available_mb: { type: integer }
|
||||
ae_running: { type: boolean }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
current_frame_job_id: { type: string, format: uuid, nullable: true }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
cache_used_gb: { type: integer }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.crashed.v1 — AfterFX crashed mid-render
|
||||
# -------------------------------------------------------------------
|
||||
node.crashed.v1:
|
||||
routing_key: node.crashed.v1
|
||||
description: AE process exited unexpectedly while rendering.
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, crashed_at]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
render_job_id: { type: string, format: uuid, nullable: true }
|
||||
frame_job_id: { type: string, format: uuid, nullable: true }
|
||||
crashed_at: { type: string, format: date-time }
|
||||
last_known_frame: { type: integer, nullable: true }
|
||||
crash_signal: { type: string, nullable: true }
|
||||
ae_version: { type: string }
|
||||
error_log_tail: { type: string, description: "Last ~50 lines of AE log" }
|
||||
log_file_url: { type: string, nullable: true }
|
||||
auto_recovery_started: { type: boolean }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.cache.updated.v1 — template cache changed (download or evict)
|
||||
# -------------------------------------------------------------------
|
||||
node.cache.updated.v1:
|
||||
routing_key: node.cache.updated.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, action, project_id, aep_file_md5]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
|
||||
project_id: { type: string, format: uuid }
|
||||
aep_file_md5: { type: string }
|
||||
file_size_bytes: { type: integer, format: int64 }
|
||||
cache_used_gb: { type: integer, description: "Total cache size after action" }
|
||||
duration_ms: { type: integer, nullable: true }
|
||||
error_message: { type: string, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.frame.completed.v1 — single frame done (high-frequency)
|
||||
# NOT on topic; sent via direct push to orchestrator.
|
||||
# -------------------------------------------------------------------
|
||||
node.frame.completed.v1:
|
||||
routing_key: node.frame.completed.v1
|
||||
transport: HTTP POST /v1/internal/render/jobs/{job_id}/frames
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, frame_job_id, frame_number]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
frame_job_id: { type: string, format: uuid }
|
||||
frame_number: { type: integer }
|
||||
file_size_bytes: { type: integer }
|
||||
completed_at: { type: string, format: date-time }
|
||||
@@ -0,0 +1,66 @@
|
||||
# =====================================================================
|
||||
# Notification Events
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
notification.created.v1:
|
||||
routing_key: notification.created.v1
|
||||
description: A new in-app notification was created (also fans out to channels).
|
||||
payload:
|
||||
type: object
|
||||
required: [notification_id, user_id, notification_type, title, message]
|
||||
properties:
|
||||
notification_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
notification_type:
|
||||
type: string
|
||||
enum: [RenderCompleted, RenderFailed, RenderProgress,
|
||||
PlanExpiring, PlanExpired, PaymentSuccess, PaymentFailed,
|
||||
StorageWarning, StorageFull, ExportExpiring, ExportDeleted,
|
||||
GiftEarned, QuestCompleted, LevelUp,
|
||||
AccountSecurity, SystemAnnouncement, TenantInvite,
|
||||
Marketing, Other]
|
||||
priority: { type: string, enum: [Low, Normal, High, Urgent] }
|
||||
title: { type: string }
|
||||
message: { type: string }
|
||||
action_url: { type: string, nullable: true }
|
||||
# Fan-out routing — which channels to deliver to
|
||||
channels:
|
||||
type: array
|
||||
items: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
|
||||
|
||||
notification.delivered.v1:
|
||||
routing_key: notification.delivered.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [delivery_id, notification_id, channel, status]
|
||||
properties:
|
||||
delivery_id: { type: string, format: uuid }
|
||||
notification_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
channel: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
|
||||
status: { type: string, enum: [Sent, Delivered] }
|
||||
provider: { type: string }
|
||||
provider_message_id: { type: string, nullable: true }
|
||||
sent_at: { type: string, format: date-time }
|
||||
delivered_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
notification.failed.v1:
|
||||
routing_key: notification.failed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [delivery_id, channel, error_message]
|
||||
properties:
|
||||
delivery_id: { type: string, format: uuid }
|
||||
notification_id: { type: string, format: uuid, nullable: true }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
channel: { type: string }
|
||||
attempt: { type: integer }
|
||||
max_attempts: { type: integer }
|
||||
error_code: { type: string, nullable: true }
|
||||
error_message: { type: string }
|
||||
will_retry: { type: boolean }
|
||||
next_retry_at: { type: string, format: date-time, nullable: true }
|
||||
@@ -0,0 +1,219 @@
|
||||
# =====================================================================
|
||||
# Render Events — published by Render Orchestrator
|
||||
# Routing: flatrender.events (topic) — key: render.*.v1
|
||||
# =====================================================================
|
||||
$schema: "http://json-schema.org/draft-07/schema#"
|
||||
title: Render Event Schemas
|
||||
type: object
|
||||
|
||||
definitions:
|
||||
EventEnvelope:
|
||||
type: object
|
||||
required: [event_id, event_type, event_time, tenant_id, data]
|
||||
properties:
|
||||
event_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
event_time: { type: string, format: date-time }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
trace_id: { type: string, format: uuid }
|
||||
correlation_id: { type: string, format: uuid }
|
||||
producer: { type: string, const: render-orchestrator }
|
||||
data: { type: object }
|
||||
|
||||
events:
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.queued.v1
|
||||
# Emitted when a job is accepted into the queue.
|
||||
# Consumed by: notification (none yet), analytics
|
||||
# -------------------------------------------------------------------
|
||||
render.job.queued.v1:
|
||||
routing_key: render.job.queued.v1
|
||||
description: New render job has been accepted into a priority queue.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, saved_project_id, priority_queue, price_type, region]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
priority_queue:
|
||||
type: string
|
||||
enum: [snapshot, vip, paid, preview, mockup, voiceover]
|
||||
priority_score: { type: integer, minimum: 0, maximum: 100 }
|
||||
price_type:
|
||||
type: string
|
||||
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
region: { type: string }
|
||||
duration_sec: { type: number }
|
||||
resolution: { type: string }
|
||||
is_60_fps: { type: boolean }
|
||||
has_music: { type: boolean }
|
||||
has_sfx: { type: boolean }
|
||||
has_voiceover: { type: boolean }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.started.v1
|
||||
# Emitted once nodes are assigned and rendering begins.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.started.v1:
|
||||
routing_key: render.job.started.v1
|
||||
description: Render job has begun processing on at least one node.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, started_at]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
started_at: { type: string, format: date-time }
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
region: { type: string }
|
||||
total_frames: { type: integer }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.progress.v1 (high-frequency, optional pub/sub)
|
||||
# NOT broadcast on the main events exchange — only on per-job
|
||||
# ephemeral fanout for WebSocket fan-out. Schema documented here.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.progress.v1:
|
||||
routing_key: render.job.progress.{job_id}
|
||||
description: Progress tick for live UI update.
|
||||
exchange: render.progress # separate dedicated exchange (auto-delete fanout)
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, step, progress]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
step:
|
||||
type: string
|
||||
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
|
||||
Rendering, Validating, Repairing, Optimisation, Video,
|
||||
Mixing, Final, Uploading, Done, Failed, Cancelled]
|
||||
progress: { type: integer, minimum: 0, maximum: 100 }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
total_frames: { type: integer, nullable: true }
|
||||
eta_seconds: { type: integer, nullable: true }
|
||||
preview_b64:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Last rendered frame thumbnail (small, ~5-15 KB)
|
||||
message: { type: string, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.completed.v1
|
||||
# Consumed by: notification, studio (mark project), file (cleanup),
|
||||
# tenant (usage metering), webhook dispatcher
|
||||
# -------------------------------------------------------------------
|
||||
render.job.completed.v1:
|
||||
routing_key: render.job.completed.v1
|
||||
description: Render job successfully produced an export.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, export_id, output_url, duration_sec, size_bytes]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
export_id: { type: string, format: uuid }
|
||||
output_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
duration_sec: { type: number }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
resolution: { type: string }
|
||||
width: { type: integer }
|
||||
height: { type: integer }
|
||||
render_compute_seconds: { type: integer, description: "Sum of node-seconds spent" }
|
||||
node_ids_used:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
price_type: { type: string }
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.failed.v1
|
||||
# Consumed by: notification, identity (refund), tenant (metering)
|
||||
# -------------------------------------------------------------------
|
||||
render.job.failed.v1:
|
||||
routing_key: render.job.failed.v1
|
||||
description: Render job failed permanently (after retries exhausted).
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, failed_at_step, error_message]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
failed_at_step:
|
||||
type: string
|
||||
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
|
||||
Rendering, Validating, Repairing, Optimisation, Video,
|
||||
Mixing, Final, Uploading, Done, Failed, Cancelled]
|
||||
error_message: { type: string }
|
||||
error_code: { type: string, nullable: true }
|
||||
retry_count: { type: integer }
|
||||
refund_required: { type: boolean, default: true }
|
||||
price_type: { type: string }
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
node_ids_attempted:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.cancelled.v1
|
||||
# User cancelled via UI before completion.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.cancelled.v1:
|
||||
routing_key: render.job.cancelled.v1
|
||||
description: Render job was cancelled by user.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, cancelled_by_user_id]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
cancelled_by_user_id: { type: string, format: uuid }
|
||||
cancelled_at_step: { type: string }
|
||||
progress_when_cancelled: { type: integer }
|
||||
refund_required: { type: boolean, default: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.snapshot.requested.v1
|
||||
# User asked for a single-frame snapshot of a scene.
|
||||
# -------------------------------------------------------------------
|
||||
render.snapshot.requested.v1:
|
||||
routing_key: render.snapshot.requested.v1
|
||||
description: Scene snapshot was queued.
|
||||
payload:
|
||||
type: object
|
||||
required: [snapshot_id, saved_project_id, scene_key, frame_number]
|
||||
properties:
|
||||
snapshot_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer }
|
||||
cached:
|
||||
type: boolean
|
||||
description: True if served from cache (no render needed)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.snapshot.ready.v1
|
||||
# Single-frame snapshot finished.
|
||||
# -------------------------------------------------------------------
|
||||
render.snapshot.ready.v1:
|
||||
routing_key: render.snapshot.ready.v1
|
||||
description: Snapshot image is ready.
|
||||
payload:
|
||||
type: object
|
||||
required: [snapshot_id, image_url]
|
||||
properties:
|
||||
snapshot_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer }
|
||||
image_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
width: { type: integer }
|
||||
height: { type: integer }
|
||||
size_bytes: { type: integer }
|
||||
duration_ms: { type: integer }
|
||||
expires_at: { type: string, format: date-time }
|
||||
@@ -0,0 +1,32 @@
|
||||
# =====================================================================
|
||||
# Studio Events — published by Studio Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
studio.project.saved.v1:
|
||||
routing_key: studio.project.saved.v1
|
||||
description: User saved their project (used to trigger autosave events, analytics).
|
||||
payload:
|
||||
type: object
|
||||
required: [saved_project_id, user_id]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
choose_mode: { type: string }
|
||||
scene_count: { type: integer }
|
||||
is_first_save: { type: boolean }
|
||||
|
||||
studio.project.deleted.v1:
|
||||
routing_key: studio.project.deleted.v1
|
||||
description: User deleted a saved project (moved to trash or hard-deleted).
|
||||
payload:
|
||||
type: object
|
||||
required: [saved_project_id, user_id, hard_delete]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
hard_delete: { type: boolean }
|
||||
@@ -0,0 +1,72 @@
|
||||
# =====================================================================
|
||||
# Tenant Events — multi-tenancy / reseller-specific
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
tenant.usage.recorded.v1:
|
||||
routing_key: tenant.usage.recorded.v1
|
||||
description: Daily usage aggregate published (by usage aggregator cron).
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, usage_date, renders_completed, render_seconds]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
usage_date: { type: string, format: date }
|
||||
renders_started: { type: integer }
|
||||
renders_completed: { type: integer }
|
||||
renders_failed: { type: integer }
|
||||
render_seconds: { type: integer, format: int64 }
|
||||
render_compute_sec: { type: integer, format: int64 }
|
||||
storage_bytes: { type: integer, format: int64 }
|
||||
api_calls: { type: integer, format: int64 }
|
||||
active_users: { type: integer }
|
||||
new_users: { type: integer }
|
||||
amount_billed_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
|
||||
tenant.webhook.fired.v1:
|
||||
routing_key: tenant.webhook.fired.v1
|
||||
description: A webhook was successfully delivered to a reseller.
|
||||
payload:
|
||||
type: object
|
||||
required: [webhook_id, tenant_id, event_type, response_status]
|
||||
properties:
|
||||
webhook_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
delivery_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
request_url: { type: string }
|
||||
response_status: { type: integer }
|
||||
duration_ms: { type: integer }
|
||||
attempt: { type: integer }
|
||||
|
||||
tenant.webhook.failed.v1:
|
||||
routing_key: tenant.webhook.failed.v1
|
||||
description: A webhook delivery exhausted retries.
|
||||
payload:
|
||||
type: object
|
||||
required: [webhook_id, tenant_id, event_type, last_status, last_error]
|
||||
properties:
|
||||
webhook_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
delivery_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
request_url: { type: string }
|
||||
last_status: { type: integer, nullable: true }
|
||||
last_error: { type: string }
|
||||
attempts: { type: integer }
|
||||
webhook_disabled: { type: boolean, description: "True if auto-disabled" }
|
||||
|
||||
tenant.api.rate_limited.v1:
|
||||
routing_key: tenant.api.rate_limited.v1
|
||||
description: A tenant exceeded its API rate limit (informational).
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, api_key_id, limit_rpm, window_start]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
api_key_id: { type: string, format: uuid }
|
||||
limit_rpm: { type: integer }
|
||||
actual_rpm: { type: integer }
|
||||
window_start: { type: string, format: date-time }
|
||||
ip_address: { type: string }
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,823 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: FlatRender Render Orchestrator (internal)
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Render job orchestration, node pool management, snapshots, exports.
|
||||
Owned by Go service. The browser connects to WebSocket via Gateway
|
||||
for live progress.
|
||||
|
||||
servers:
|
||||
- url: http://render-svc.internal/v1
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ServiceToken: []
|
||||
|
||||
tags:
|
||||
- name: Jobs
|
||||
- name: Snapshots
|
||||
- name: Exports
|
||||
- name: Nodes
|
||||
- name: Admin
|
||||
- name: Internal
|
||||
|
||||
paths:
|
||||
|
||||
# ===================== JOBS =====================
|
||||
/renders:
|
||||
get:
|
||||
tags: [Jobs]
|
||||
summary: List user's render jobs
|
||||
parameters:
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
- { name: page, in: query, schema: { type: integer } }
|
||||
- { name: page_size, in: query, schema: { type: integer } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/RenderJob' } }
|
||||
meta: { $ref: '#/components/schemas/PaginationMeta' }
|
||||
post:
|
||||
tags: [Jobs]
|
||||
summary: Submit new render job
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderJobCreate' }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderJob' }
|
||||
'402': { description: Insufficient balance/charge }
|
||||
|
||||
/renders/{job_id}:
|
||||
get:
|
||||
tags: [Jobs]
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderJobDetail' }
|
||||
|
||||
/renders/{job_id}/cancel:
|
||||
post:
|
||||
tags: [Jobs]
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
cancelled: { type: boolean }
|
||||
refund_amount_minor: { type: integer, format: int64 }
|
||||
|
||||
/renders/{job_id}/retry:
|
||||
post:
|
||||
tags: [Jobs]
|
||||
summary: Retry a failed render (uses same config)
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderJob' }
|
||||
|
||||
/renders/{job_id}/progress:
|
||||
get:
|
||||
tags: [Jobs]
|
||||
summary: |
|
||||
Fallback polling endpoint when WebSocket isn't usable.
|
||||
Browser primary is WS at /ws/v1/render/{job_id}.
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderProgress' }
|
||||
|
||||
/renders/{job_id}/logs:
|
||||
get:
|
||||
tags: [Jobs]
|
||||
summary: Get render execution logs (admin or owner)
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
- { name: level, in: query, schema: { type: string, enum: [debug, info, warn, error] } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
logs:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
timestamp: { type: string, format: date-time }
|
||||
level: { type: string }
|
||||
node_id: { type: string, format: uuid, nullable: true }
|
||||
message: { type: string }
|
||||
meta: { type: object }
|
||||
|
||||
# ===================== SNAPSHOTS =====================
|
||||
/snapshots:
|
||||
post:
|
||||
tags: [Snapshots]
|
||||
summary: Request single-frame snapshot of a scene
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [saved_project_id, scene_key, frame_number]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer, minimum: 0 }
|
||||
responses:
|
||||
'202':
|
||||
description: Snapshot queued (or returned immediately if cached)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
snapshot_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Cached, Pending, Rendering] }
|
||||
image_url: { type: string, nullable: true }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
expires_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
/snapshots/{snapshot_id}:
|
||||
get:
|
||||
tags: [Snapshots]
|
||||
parameters:
|
||||
- { name: snapshot_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Snapshot' }
|
||||
|
||||
# ===================== EXPORTS =====================
|
||||
/exports:
|
||||
get:
|
||||
tags: [Exports]
|
||||
summary: List user's exports
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/Export' } }
|
||||
meta: { $ref: '#/components/schemas/PaginationMeta' }
|
||||
|
||||
/exports/{export_id}:
|
||||
get:
|
||||
tags: [Exports]
|
||||
parameters:
|
||||
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ExportDetail' }
|
||||
delete:
|
||||
tags: [Exports]
|
||||
parameters:
|
||||
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'204': { description: Deleted }
|
||||
|
||||
/exports/{export_id}/extend:
|
||||
post:
|
||||
tags: [Exports]
|
||||
summary: Extend auto-delete date (paid feature)
|
||||
parameters:
|
||||
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
days: { type: integer, minimum: 1, maximum: 365 }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
new_auto_delete_date: { type: string, format: date-time }
|
||||
|
||||
/exports/{export_id}/download-url:
|
||||
get:
|
||||
tags: [Exports]
|
||||
summary: Get presigned MinIO URL (short-lived)
|
||||
parameters:
|
||||
- { name: export_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
- { name: format, in: query, schema: { type: string, default: mp4 } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
expires_at: { type: string, format: date-time }
|
||||
|
||||
# ===================== NODES (admin) =====================
|
||||
/nodes:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
summary: List nodes
|
||||
parameters:
|
||||
- { name: region, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/RenderNode' } }
|
||||
post:
|
||||
tags: [Nodes]
|
||||
summary: (Admin) Register new node
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderNodeCreate' }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderNode' }
|
||||
|
||||
/nodes/{node_id}:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderNodeDetail' }
|
||||
patch:
|
||||
tags: [Nodes]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
priority: { type: integer }
|
||||
is_active: { type: boolean }
|
||||
accepts_new_jobs: { type: boolean }
|
||||
node_kind: { type: string }
|
||||
owner_user_id: { type: string, format: uuid }
|
||||
next_maintenance_at: { type: string, format: date-time }
|
||||
maintenance_reason: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/RenderNode' }
|
||||
|
||||
/nodes/{node_id}/restart:
|
||||
post:
|
||||
tags: [Nodes]
|
||||
summary: Reboot node OS
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'202': { description: Restart issued }
|
||||
|
||||
/nodes/{node_id}/release:
|
||||
post:
|
||||
tags: [Nodes]
|
||||
summary: Force-release node from any current job
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'204': { description: Released }
|
||||
|
||||
/nodes/{node_id}/close-ae:
|
||||
post:
|
||||
tags: [Nodes]
|
||||
summary: Force-kill AfterFX on a node
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'204': { description: AE closed }
|
||||
|
||||
/nodes/{node_id}/health:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
summary: Current node health
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NodeHealth' }
|
||||
|
||||
/nodes/{node_id}/health/history:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
- { name: from, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: to, in: query, schema: { type: string, format: date-time } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/NodeHealth' }
|
||||
|
||||
/nodes/{node_id}/crashes:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/NodeCrash' } }
|
||||
|
||||
/node-updates:
|
||||
get:
|
||||
tags: [Nodes]
|
||||
summary: Available node software updates
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/NodeUpdate' } }
|
||||
|
||||
/node-updates/{update_id}/rollout:
|
||||
post:
|
||||
tags: [Nodes]
|
||||
summary: Trigger rollout to selected nodes
|
||||
parameters:
|
||||
- { name: update_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
node_ids: { type: array, items: { type: string, format: uuid } }
|
||||
responses:
|
||||
'202': { description: Rollout queued }
|
||||
|
||||
# ===================== INTERNAL (called by node agents) =====================
|
||||
/internal/nodes/{node_id}/heartbeat:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node sends heartbeat (every 5s)
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NodeHeartbeat' }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
next_heartbeat_in_sec: { type: integer }
|
||||
pending_commands:
|
||||
type: array
|
||||
description: e.g. "cancel current job", "update software"
|
||||
items: { type: object }
|
||||
|
||||
/internal/nodes/{node_id}/online:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node agent reports it just started
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
node_agent_version: { type: string }
|
||||
current_ae_version: { type: string }
|
||||
available_ae_versions: { type: array, items: { type: string } }
|
||||
ram_gb: { type: integer }
|
||||
cpu_cores: { type: integer }
|
||||
cache_used_gb: { type: integer }
|
||||
cached_template_md5s: { type: array, items: { type: string } }
|
||||
responses:
|
||||
'200': { description: Acknowledged }
|
||||
|
||||
/internal/render/jobs/{job_id}/frames:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node pushes per-frame progress
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [frame_job_id, frame_number]
|
||||
properties:
|
||||
frame_job_id: { type: string, format: uuid }
|
||||
frame_number: { type: integer }
|
||||
file_size_bytes: { type: integer }
|
||||
completed_at: { type: string, format: date-time }
|
||||
responses:
|
||||
'204': { description: Recorded }
|
||||
|
||||
/internal/render/jobs/{job_id}/crash:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node reports an AE crash on this job
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [node_id]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
frame_job_id: { type: string, format: uuid }
|
||||
last_known_frame: { type: integer }
|
||||
crash_signal: { type: string }
|
||||
ae_version: { type: string }
|
||||
error_log_tail: { type: string }
|
||||
log_file_url: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
action_recommended:
|
||||
type: string
|
||||
enum: [ResetAndRestart, ReassignWork, RestartNode]
|
||||
reassigned_to_node_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
|
||||
/internal/render/jobs/{job_id}/replica-ready:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node reports replica .aep saved (after JSX run)
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: job_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [node_id, replica_path]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
replica_path: { type: string }
|
||||
replica_md5: { type: string }
|
||||
responses:
|
||||
'204': { description: Acknowledged }
|
||||
|
||||
/internal/nodes/{node_id}/cache-update:
|
||||
post:
|
||||
tags: [Internal]
|
||||
summary: Node reports a template cache change
|
||||
security: [NodeHmac: []]
|
||||
parameters:
|
||||
- { name: node_id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [action, aep_file_md5]
|
||||
properties:
|
||||
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
|
||||
project_id: { type: string, format: uuid }
|
||||
aep_file_md5: { type: string }
|
||||
file_size_bytes: { type: integer, format: int64 }
|
||||
cache_used_gb: { type: integer }
|
||||
error_message: { type: string }
|
||||
responses:
|
||||
'204': { description: Recorded }
|
||||
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
ServiceToken: { type: http, scheme: bearer }
|
||||
NodeHmac:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Node-Signature
|
||||
|
||||
schemas:
|
||||
PaginationMeta:
|
||||
type: object
|
||||
properties:
|
||||
page: { type: integer }
|
||||
page_size: { type: integer }
|
||||
total: { type: integer }
|
||||
has_more: { type: boolean }
|
||||
|
||||
RenderJob:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
step: { type: string }
|
||||
render_progress: { type: integer }
|
||||
priority_queue: { type: string }
|
||||
price_type: { type: string }
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
quality: { type: string }
|
||||
resolution: { type: string }
|
||||
frame_rate: { type: integer }
|
||||
duration_sec: { type: number }
|
||||
has_voiceover: { type: boolean }
|
||||
image_preview_b64: { type: string, nullable: true }
|
||||
failed_message: { type: string, nullable: true }
|
||||
export_id: { type: string, format: uuid, nullable: true }
|
||||
queued_at: { type: string, format: date-time }
|
||||
started_at: { type: string, format: date-time, nullable: true }
|
||||
completed_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
RenderJobCreate:
|
||||
type: object
|
||||
required: [saved_project_id, quality, resolution]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
quality: { type: string, enum: [Low, Medium, High, Full, Lossless] }
|
||||
resolution: { type: string, enum: [HD, FullHD, TwoK, FourK] }
|
||||
frame_rate: { type: integer, enum: [24, 25, 30, 60] }
|
||||
is_60_fps: { type: boolean }
|
||||
price_type:
|
||||
type: string
|
||||
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
|
||||
discount_code: { type: string }
|
||||
support_flatrender: { type: boolean }
|
||||
tell_me_when_done: { type: boolean }
|
||||
preferred_region: { type: string }
|
||||
|
||||
RenderJobDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RenderJob'
|
||||
- type: object
|
||||
properties:
|
||||
frame_jobs:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/FrameJob' }
|
||||
retry_count: { type: integer }
|
||||
repair_attempts: { type: integer }
|
||||
export: { $ref: '#/components/schemas/Export', nullable: true }
|
||||
|
||||
FrameJob:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
node_id: { type: string, format: uuid }
|
||||
start_frame: { type: integer }
|
||||
end_frame: { type: integer }
|
||||
order_value: { type: integer }
|
||||
status: { type: string }
|
||||
frames_rendered: { type: integer }
|
||||
frames_validated: { type: integer }
|
||||
attempt: { type: integer }
|
||||
last_error: { type: string, nullable: true }
|
||||
|
||||
RenderProgress:
|
||||
type: object
|
||||
properties:
|
||||
job_id: { type: string, format: uuid }
|
||||
step: { type: string }
|
||||
progress: { type: integer }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
total_frames: { type: integer, nullable: true }
|
||||
eta_seconds: { type: integer, nullable: true }
|
||||
preview_b64: { type: string, nullable: true }
|
||||
active_nodes: { type: integer }
|
||||
message: { type: string, nullable: true }
|
||||
|
||||
Snapshot:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer }
|
||||
status: { type: string }
|
||||
image_url: { type: string, nullable: true }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
width: { type: integer, nullable: true }
|
||||
height: { type: integer, nullable: true }
|
||||
duration_ms: { type: integer, nullable: true }
|
||||
expires_at: { type: string, format: date-time }
|
||||
requested_at: { type: string, format: date-time }
|
||||
completed_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
Export:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
path: { type: string }
|
||||
image: { type: string }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
duration_sec: { type: number }
|
||||
width: { type: integer }
|
||||
height: { type: integer }
|
||||
file_extension: { type: string }
|
||||
file_type: { type: string }
|
||||
render_quality: { type: string }
|
||||
create_type: { type: string }
|
||||
produce_date: { type: string, format: date-time }
|
||||
auto_delete_date: { type: string, format: date-time }
|
||||
|
||||
ExportDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/Export'
|
||||
- type: object
|
||||
properties:
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
thumbnail: { type: string }
|
||||
path: { type: string }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
file_type: { type: string }
|
||||
|
||||
RenderNode:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
region: { type: string }
|
||||
node_ip: { type: string }
|
||||
worker_port: { type: integer }
|
||||
status: { type: string }
|
||||
node_kind: { type: string }
|
||||
owner_user_id: { type: string, format: uuid, nullable: true }
|
||||
current_ae_version: { type: string }
|
||||
node_agent_version: { type: string }
|
||||
ram_gb: { type: integer }
|
||||
cpu_cores: { type: integer }
|
||||
priority: { type: integer }
|
||||
is_active: { type: boolean }
|
||||
accepts_new_jobs: { type: boolean }
|
||||
last_heartbeat_at: { type: string, format: date-time }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
last_cpu_pct: { type: integer }
|
||||
last_ram_available_mb: { type: integer }
|
||||
ae_running: { type: boolean }
|
||||
cache_used_gb: { type: integer }
|
||||
cached_template_count: { type: integer }
|
||||
lifetime_task_count: { type: integer, format: int64 }
|
||||
lifetime_crash_count: { type: integer }
|
||||
consecutive_failures: { type: integer }
|
||||
|
||||
RenderNodeCreate:
|
||||
type: object
|
||||
required: [name, region, node_ip, worker_port, current_ae_version]
|
||||
properties:
|
||||
name: { type: string }
|
||||
region: { type: string }
|
||||
node_ip: { type: string }
|
||||
worker_port: { type: integer }
|
||||
current_ae_version: { type: string }
|
||||
ram_gb: { type: integer }
|
||||
cpu_cores: { type: integer }
|
||||
node_kind: { type: string }
|
||||
owner_user_id: { type: string, format: uuid }
|
||||
priority: { type: integer }
|
||||
|
||||
RenderNodeDetail:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RenderNode'
|
||||
- type: object
|
||||
properties:
|
||||
available_ae_versions: { type: array, items: { type: string } }
|
||||
cached_template_md5s: { type: array, items: { type: string } }
|
||||
last_maintenance_at: { type: string, format: date-time }
|
||||
next_maintenance_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
NodeHealth:
|
||||
type: object
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
recorded_at: { type: string, format: date-time }
|
||||
status: { type: string }
|
||||
cpu_pct: { type: integer }
|
||||
ram_available_mb: { type: integer }
|
||||
ae_running: { type: boolean }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
cache_used_gb: { type: integer }
|
||||
|
||||
NodeHeartbeat:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/NodeHealth'
|
||||
|
||||
NodeCrash:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
node_id: { type: string, format: uuid }
|
||||
render_job_id: { type: string, format: uuid, nullable: true }
|
||||
crashed_at: { type: string, format: date-time }
|
||||
last_known_frame: { type: integer, nullable: true }
|
||||
crash_signal: { type: string }
|
||||
error_log: { type: string }
|
||||
log_file_url: { type: string }
|
||||
auto_recovered: { type: boolean }
|
||||
recovery_action: { type: string }
|
||||
recovered_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
NodeUpdate:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
update_file_name: { type: string }
|
||||
update_number: { type: integer }
|
||||
description: { type: string }
|
||||
target_ae_version: { type: string }
|
||||
in_update_queue: { type: boolean }
|
||||
rolled_out_to_node_ids: { type: array, items: { type: string, format: uuid } }
|
||||
create_date: { type: string, format: date-time }
|
||||
@@ -0,0 +1,661 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: FlatRender Studio Service (internal)
|
||||
version: 1.0.0
|
||||
description: |
|
||||
User's saved projects (the editor's state). Includes voiceover +
|
||||
audio mix settings.
|
||||
|
||||
servers:
|
||||
- url: http://studio-svc.internal/v1
|
||||
|
||||
security:
|
||||
- BearerAuth: []
|
||||
- ServiceToken: []
|
||||
|
||||
tags:
|
||||
- name: SavedProjects
|
||||
- name: SavedScenes
|
||||
- name: Audio
|
||||
- name: Internal
|
||||
|
||||
paths:
|
||||
|
||||
/saved-projects:
|
||||
get:
|
||||
tags: [SavedProjects]
|
||||
summary: List user's saved projects
|
||||
parameters:
|
||||
- { name: q, in: query, schema: { type: string } }
|
||||
- { name: type, in: query, schema: { type: string, enum: [Draft, Active, Archived, Trash] } }
|
||||
- { name: page, in: query, schema: { type: integer } }
|
||||
- { name: page_size, in: query, schema: { type: integer } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/SavedProjectSummary' } }
|
||||
meta: { $ref: '#/components/schemas/PaginationMeta' }
|
||||
|
||||
post:
|
||||
tags: [SavedProjects]
|
||||
summary: Create new saved project from a template
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [original_project_id]
|
||||
properties:
|
||||
original_project_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
preset_story_id: { type: string, format: uuid }
|
||||
copy_default_values: { type: boolean, default: true }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProject' }
|
||||
|
||||
/saved-projects/{id}:
|
||||
get:
|
||||
tags: [SavedProjects]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProjectFull' }
|
||||
patch:
|
||||
tags: [SavedProjects]
|
||||
summary: Update top-level fields (name, audio, etc.)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProjectUpdate' }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProject' }
|
||||
delete:
|
||||
tags: [SavedProjects]
|
||||
summary: Soft-delete (move to trash)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'204': { description: Trashed }
|
||||
|
||||
/saved-projects/{id}/restore:
|
||||
post:
|
||||
tags: [SavedProjects]
|
||||
summary: Restore from trash
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProject' }
|
||||
|
||||
/saved-projects/{id}/duplicate:
|
||||
post:
|
||||
tags: [SavedProjects]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
new_name: { type: string }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProject' }
|
||||
|
||||
/saved-projects/{id}/autosave:
|
||||
put:
|
||||
tags: [SavedProjects]
|
||||
summary: Autosave entire project graph (debounced from UI)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProjectFull' }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
saved_at: { type: string, format: date-time }
|
||||
version: { type: integer }
|
||||
|
||||
# ===================== AUDIO (NEW) =====================
|
||||
/saved-projects/{id}/audio:
|
||||
get:
|
||||
tags: [Audio]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AudioSettings' }
|
||||
put:
|
||||
tags: [Audio]
|
||||
summary: Update audio mix
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AudioSettings' }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AudioSettings' }
|
||||
|
||||
/saved-projects/{id}/voiceover:
|
||||
post:
|
||||
tags: [Audio]
|
||||
summary: |
|
||||
Upload or record voiceover. Returns target file_id.
|
||||
Use file service upload endpoints for actual binary; this
|
||||
attaches an existing file to the project.
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [file_id]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid, description: existing user_file_id }
|
||||
recorded_in_browser: { type: boolean, default: false }
|
||||
volume: { type: number, minimum: 0, maximum: 1, default: 1.0 }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AudioSettings' }
|
||||
delete:
|
||||
tags: [Audio]
|
||||
summary: Remove voiceover
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'204': { description: Removed }
|
||||
|
||||
/saved-projects/{id}/music:
|
||||
put:
|
||||
tags: [Audio]
|
||||
summary: Set music track (from library or upload)
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
music_track_id: { type: string, format: uuid }
|
||||
music_file_id: { type: string, format: uuid }
|
||||
volume: { type: number, minimum: 0, maximum: 1 }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/AudioSettings' }
|
||||
|
||||
# ===================== SCENES =====================
|
||||
/saved-projects/{id}/scenes:
|
||||
get:
|
||||
tags: [SavedScenes]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
|
||||
post:
|
||||
tags: [SavedScenes]
|
||||
summary: Add a scene from project template
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [original_scene_id]
|
||||
properties:
|
||||
original_scene_id: { type: string, format: uuid }
|
||||
sort: { type: integer }
|
||||
scene_length_sec: { type: number }
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedSceneFull' }
|
||||
|
||||
/saved-scenes/{scene_id}:
|
||||
patch:
|
||||
tags: [SavedScenes]
|
||||
parameters:
|
||||
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
scene_length_sec: { type: number }
|
||||
manual_color_selection: { type: boolean }
|
||||
selected_color_preset_id: { type: integer, format: int64 }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedScene' }
|
||||
delete:
|
||||
tags: [SavedScenes]
|
||||
parameters:
|
||||
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
responses:
|
||||
'204': { description: Removed }
|
||||
|
||||
/saved-projects/{id}/scenes/reorder:
|
||||
post:
|
||||
tags: [SavedScenes]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [ordered_ids]
|
||||
properties:
|
||||
ordered_ids:
|
||||
type: array
|
||||
items: { type: integer, format: int64 }
|
||||
responses:
|
||||
'204': { description: Reordered }
|
||||
|
||||
/saved-scenes/{scene_id}/contents:
|
||||
put:
|
||||
tags: [SavedScenes]
|
||||
summary: Bulk-update contents for a scene
|
||||
parameters:
|
||||
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
contents:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSceneContent' }
|
||||
responses:
|
||||
'200': { description: Updated }
|
||||
|
||||
/saved-scenes/{scene_id}/colors:
|
||||
put:
|
||||
tags: [SavedScenes]
|
||||
summary: Bulk-update colors for a scene
|
||||
parameters:
|
||||
- { name: scene_id, in: path, required: true, schema: { type: integer, format: int64 } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
colors:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSceneColor' }
|
||||
responses:
|
||||
'200': { description: Updated }
|
||||
|
||||
/saved-projects/{id}/shared-colors:
|
||||
put:
|
||||
tags: [SavedProjects]
|
||||
summary: Bulk update project-level shared colors
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
colors:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSharedColor' }
|
||||
responses:
|
||||
'200': { description: Updated }
|
||||
|
||||
/saved-projects/{id}/shared-layers:
|
||||
put:
|
||||
tags: [SavedProjects]
|
||||
summary: Bulk update project-level shared layers
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
layers:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSharedLayer' }
|
||||
responses:
|
||||
'200': { description: Updated }
|
||||
|
||||
# ===================== INTERNAL =====================
|
||||
/internal/saved-projects/{id}/snapshot-for-render:
|
||||
get:
|
||||
tags: [Internal]
|
||||
summary: |
|
||||
Called by Render Orchestrator to get the full JSX-ready payload.
|
||||
Returns everything needed to generate JSX (FIX/FLEXIBLE/Mockup/MV).
|
||||
security: [ServiceToken: []]
|
||||
parameters:
|
||||
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/SavedProjectSnapshot' }
|
||||
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
ServiceToken: { type: http, scheme: bearer }
|
||||
|
||||
schemas:
|
||||
PaginationMeta:
|
||||
type: object
|
||||
properties:
|
||||
page: { type: integer }
|
||||
page_size: { type: integer }
|
||||
total: { type: integer }
|
||||
has_more: { type: boolean }
|
||||
|
||||
SavedProjectSummary:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
image: { type: string }
|
||||
type: { type: string }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
original_project_name: { type: string }
|
||||
original_container_slug: { type: string }
|
||||
choose_mode: { type: string }
|
||||
resolution: { type: string }
|
||||
project_duration_sec: { type: number }
|
||||
scene_count: { type: integer }
|
||||
last_edit_date: { type: string, format: date-time }
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
SavedProject:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SavedProjectSummary'
|
||||
- type: object
|
||||
properties:
|
||||
frame_rate: { type: integer }
|
||||
vip_factor: { type: number }
|
||||
manual_color_picker: { type: boolean }
|
||||
selected_preset_story_id: { type: string, format: uuid, nullable: true }
|
||||
audio: { $ref: '#/components/schemas/AudioSettings' }
|
||||
|
||||
SavedProjectUpdate:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
type: { type: string, enum: [Draft, Active, Archived, Trash] }
|
||||
manual_color_picker: { type: boolean }
|
||||
selected_preset_story_id: { type: string, format: uuid, nullable: true }
|
||||
last_edit_step: { type: string }
|
||||
|
||||
SavedProjectFull:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SavedProject'
|
||||
- type: object
|
||||
properties:
|
||||
scenes: { type: array, items: { $ref: '#/components/schemas/SavedSceneFull' } }
|
||||
shared_colors: { type: array, items: { $ref: '#/components/schemas/SavedSharedColor' } }
|
||||
shared_color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSharedColorPreset' } }
|
||||
shared_layers: { type: array, items: { $ref: '#/components/schemas/SavedSharedLayer' } }
|
||||
|
||||
AudioSettings:
|
||||
type: object
|
||||
properties:
|
||||
music_track_id: { type: string, format: uuid, nullable: true }
|
||||
music_file_id: { type: string, format: uuid, nullable: true }
|
||||
music_url: { type: string, nullable: true }
|
||||
music_duration_sec: { type: number, nullable: true }
|
||||
music_volume: { type: number, minimum: 0, maximum: 1 }
|
||||
|
||||
voiceover_file_id: { type: string, format: uuid, nullable: true }
|
||||
voiceover_url: { type: string, nullable: true }
|
||||
voiceover_duration_sec: { type: number, nullable: true }
|
||||
voiceover_volume: { type: number, minimum: 0, maximum: 1 }
|
||||
voiceover_recorded_in_browser: { type: boolean }
|
||||
|
||||
sfx_enabled: { type: boolean }
|
||||
sfx_volume: { type: number, minimum: 0, maximum: 1 }
|
||||
|
||||
SavedScene:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
original_scene_id: { type: string, format: uuid }
|
||||
key: { type: string }
|
||||
title: { type: string }
|
||||
image: { type: string }
|
||||
demo: { type: string }
|
||||
scene_type: { type: string }
|
||||
sort: { type: integer }
|
||||
scene_length_sec: { type: number }
|
||||
min_duration_sec: { type: number }
|
||||
max_duration_sec: { type: number }
|
||||
overlap_at_end_sec: { type: number }
|
||||
manual_color_selection: { type: boolean }
|
||||
|
||||
SavedSceneFull:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SavedScene'
|
||||
- type: object
|
||||
properties:
|
||||
contents: { type: array, items: { $ref: '#/components/schemas/SavedSceneContent' } }
|
||||
colors: { type: array, items: { $ref: '#/components/schemas/SavedSceneColor' } }
|
||||
color_presets: { type: array, items: { $ref: '#/components/schemas/SavedSceneColorPreset' } }
|
||||
characters: { type: array, items: { $ref: '#/components/schemas/SavedSceneCharacter' } }
|
||||
|
||||
SavedSceneContent:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
key: { type: string }
|
||||
type: { type: string }
|
||||
value: { type: string }
|
||||
value_file_id: { type: string, format: uuid, nullable: true }
|
||||
file_url_cached: { type: string, nullable: true }
|
||||
inserted_file_type: { type: string, nullable: true }
|
||||
font_face: { type: string, nullable: true }
|
||||
font_size: { type: integer, nullable: true }
|
||||
justify: { type: string }
|
||||
position_in_container: { type: integer }
|
||||
direction_layer_value: { type: integer }
|
||||
is_text_box: { type: boolean }
|
||||
ai_input_type: { type: string, nullable: true }
|
||||
selected_dp: { type: integer, nullable: true }
|
||||
repeater_item_key: { type: string, nullable: true }
|
||||
repeater_index: { type: integer, nullable: true }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedSceneColor:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
element_key: { type: string }
|
||||
title: { type: string }
|
||||
icon: { type: string }
|
||||
attr_value: { type: string }
|
||||
value: { type: string }
|
||||
is_selected: { type: boolean }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedSceneColorPreset:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
is_selected: { type: boolean }
|
||||
sort: { type: integer }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
element_key: { type: string }
|
||||
value: { type: string }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedSceneCharacter:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
key: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
icon: { type: string }
|
||||
controllers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
key: { type: string }
|
||||
value: { type: string }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedSharedColor:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
element_key: { type: string }
|
||||
title: { type: string }
|
||||
attr_value: { type: string }
|
||||
value: { type: string }
|
||||
is_selected: { type: boolean }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedSharedColorPreset:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
name: { type: string }
|
||||
is_selected: { type: boolean }
|
||||
sort: { type: integer }
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
element_key: { type: string }
|
||||
value: { type: string }
|
||||
|
||||
SavedSharedLayer:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: integer, format: int64 }
|
||||
key: { type: string }
|
||||
title: { type: string }
|
||||
type: { type: string }
|
||||
value: { type: string }
|
||||
value_file_id: { type: string, format: uuid, nullable: true }
|
||||
file_url_cached: { type: string, nullable: true }
|
||||
font_face: { type: string }
|
||||
font_size: { type: integer }
|
||||
justify: { type: string }
|
||||
position_in_container: { type: integer }
|
||||
direction_layer_value: { type: integer }
|
||||
is_text_box: { type: boolean }
|
||||
sort: { type: integer }
|
||||
|
||||
SavedProjectSnapshot:
|
||||
type: object
|
||||
description: |
|
||||
Complete payload for JSX generation. Same shape returned for any
|
||||
choose_mode; render service decides which JSX generator to use.
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
original_project_name: { type: string }
|
||||
choose_mode: { type: string }
|
||||
resolution: { type: string }
|
||||
frame_rate: { type: integer }
|
||||
project_duration_sec: { type: number }
|
||||
vip_factor: { type: number }
|
||||
|
||||
aep:
|
||||
type: object
|
||||
properties:
|
||||
url: { type: string }
|
||||
md5: { type: string }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
render_comp: { type: string }
|
||||
original_width: { type: integer }
|
||||
original_height: { type: integer }
|
||||
|
||||
audio: { $ref: '#/components/schemas/AudioSettings' }
|
||||
|
||||
shared_colors:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSharedColor' }
|
||||
|
||||
shared_layers:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSharedLayer' }
|
||||
|
||||
scenes:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/SavedSceneFull' }
|
||||
@@ -0,0 +1,199 @@
|
||||
# WebSocket Protocol — Render Progress
|
||||
|
||||
Live render progress is pushed to the browser via WebSocket.
|
||||
|
||||
## Connection
|
||||
|
||||
```
|
||||
URL: wss://api.flatrender.ir/ws/v1/render/{job_id}?token={jwt}
|
||||
Headers: Sec-WebSocket-Protocol: flatrender.v1
|
||||
```
|
||||
|
||||
The JWT carries `user_id` + `tenant_id`. The Gateway validates that the
|
||||
caller owns the `job_id`. Connection is closed with code `4403` if not.
|
||||
|
||||
## Server → Client messages
|
||||
|
||||
All server messages are JSON with `type` discriminator.
|
||||
|
||||
### `hello` — sent on connect
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "hello",
|
||||
"job_id": "uuid",
|
||||
"current_state": {
|
||||
"step": "Rendering",
|
||||
"progress": 47,
|
||||
"current_frame": 470,
|
||||
"total_frames": 720,
|
||||
"started_at": "2026-05-27T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `progress` — frequent (max every 500ms)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "progress",
|
||||
"job_id": "uuid",
|
||||
"step": "Rendering",
|
||||
"progress": 67,
|
||||
"current_frame": 482,
|
||||
"total_frames": 720,
|
||||
"eta_seconds": 45,
|
||||
"preview_b64": "data:image/jpeg;base64,/9j/4AAQ...",
|
||||
"active_nodes": 3,
|
||||
"message": null
|
||||
}
|
||||
```
|
||||
|
||||
`preview_b64` is sent at most every 2s (last rendered frame thumbnail,
|
||||
~5-15 KB).
|
||||
|
||||
### `step_change` — pipeline transition
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "step_change",
|
||||
"job_id": "uuid",
|
||||
"from_step": "Rendering",
|
||||
"to_step": "Validating",
|
||||
"at": "2026-05-27T10:05:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `frame_repair` — frames being re-rendered
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "frame_repair",
|
||||
"job_id": "uuid",
|
||||
"missing_frames": [347, 348, 521],
|
||||
"corrupt_frames": [402],
|
||||
"attempt": 1,
|
||||
"max_attempts": 3
|
||||
}
|
||||
```
|
||||
|
||||
### `node_event` — node-related notice
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "node_event",
|
||||
"job_id": "uuid",
|
||||
"event": "node_crashed",
|
||||
"node_id": "uuid",
|
||||
"auto_recovered": true,
|
||||
"message": "AE crashed on node-7, work reassigned"
|
||||
}
|
||||
```
|
||||
|
||||
### `done` — terminal success
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "done",
|
||||
"job_id": "uuid",
|
||||
"export_id": "uuid",
|
||||
"output_url": "https://cdn.flatrender.ir/exports/abc.mp4",
|
||||
"thumbnail_url": "https://cdn.flatrender.ir/exports/abc.jpg",
|
||||
"duration_sec": 30,
|
||||
"size_bytes": 14523456,
|
||||
"compute_seconds": 124
|
||||
}
|
||||
```
|
||||
|
||||
### `failed` — terminal failure
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "failed",
|
||||
"job_id": "uuid",
|
||||
"failed_at_step": "Rendering",
|
||||
"error_message": "AE crashed too many times on this template",
|
||||
"error_code": "AE_REPEATED_CRASH",
|
||||
"refund_issued": true,
|
||||
"trace_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
### `cancelled` — terminal cancellation
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "cancelled",
|
||||
"job_id": "uuid",
|
||||
"cancelled_at": "2026-05-27T10:03:12Z",
|
||||
"progress_when_cancelled": 42
|
||||
}
|
||||
```
|
||||
|
||||
### `error` — protocol-level error (kept open)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"code": "RATE_LIMIT",
|
||||
"message": "Too many messages; slow down"
|
||||
}
|
||||
```
|
||||
|
||||
### `ping` — keepalive
|
||||
|
||||
```json
|
||||
{ "type": "ping", "t": 1714294800 }
|
||||
```
|
||||
|
||||
Client SHOULD respond with `{"type":"pong","t":1714294800}` within 30s
|
||||
or the server may close the connection.
|
||||
|
||||
## Client → Server messages
|
||||
|
||||
### `pong`
|
||||
|
||||
```json
|
||||
{ "type": "pong", "t": 1714294800 }
|
||||
```
|
||||
|
||||
### `subscribe_snapshot` — also get scene snapshot updates over same socket
|
||||
|
||||
```json
|
||||
{ "type": "subscribe_snapshot", "snapshot_id": "uuid" }
|
||||
```
|
||||
|
||||
### `cancel_job` — request cancellation
|
||||
|
||||
```json
|
||||
{ "type": "cancel_job", "job_id": "uuid" }
|
||||
```
|
||||
|
||||
The server replies with a `cancelled` message once accepted.
|
||||
|
||||
## Close codes
|
||||
|
||||
| Code | Meaning |
|
||||
|-------|------------------------------------------|
|
||||
| 1000 | Normal close (job completed/failed) |
|
||||
| 1001 | Server going away (deploy) |
|
||||
| 1008 | Policy violation (bad message) |
|
||||
| 4401 | Unauthorized (bad/expired JWT) |
|
||||
| 4403 | Forbidden (don't own this job) |
|
||||
| 4404 | Job not found |
|
||||
| 4429 | Rate limited |
|
||||
|
||||
## Reconnect strategy (client)
|
||||
|
||||
- Reconnect with exponential backoff (1s, 2s, 4s, 8s, max 30s)
|
||||
- On reconnect, `hello` carries `current_state` so UI catches up
|
||||
- WebSocket is best-effort; UI should also poll `GET /v1/renders/{id}`
|
||||
if it hasn't received `progress` in > 15s
|
||||
|
||||
## Rate limits
|
||||
|
||||
| Direction | Limit |
|
||||
|-----------|-------|
|
||||
| Server → Client `progress` | Max 2 Hz |
|
||||
| Server → Client total messages | 10 per second |
|
||||
| Client → Server | 5 per second |
|
||||
@@ -0,0 +1,90 @@
|
||||
# FlatRender V2 — Database Schemas
|
||||
|
||||
PostgreSQL 15+. Single database, one schema per microservice.
|
||||
|
||||
## Run order
|
||||
|
||||
Apply migrations in numerical order:
|
||||
|
||||
```bash
|
||||
psql -d flatrender -f migrations/00_setup.sql
|
||||
psql -d flatrender -f migrations/01_identity_tenants.sql
|
||||
psql -d flatrender -f migrations/02_identity_users.sql
|
||||
psql -d flatrender -f migrations/03_identity_billing.sql
|
||||
psql -d flatrender -f migrations/04_identity_gamification.sql
|
||||
psql -d flatrender -f migrations/05_content_taxonomy.sql
|
||||
psql -d flatrender -f migrations/06_content_projects.sql
|
||||
psql -d flatrender -f migrations/07_content_scenes.sql
|
||||
psql -d flatrender -f migrations/08_content_characters_presets.sql
|
||||
psql -d flatrender -f migrations/09_content_cms.sql
|
||||
psql -d flatrender -f migrations/10_studio_saved_projects.sql
|
||||
psql -d flatrender -f migrations/11_render_nodes.sql
|
||||
psql -d flatrender -f migrations/12_render_jobs.sql
|
||||
psql -d flatrender -f migrations/13_file_manager.sql
|
||||
psql -d flatrender -f migrations/14_notification.sql
|
||||
```
|
||||
|
||||
## Schemas
|
||||
|
||||
| Schema | Owner Service | Purpose |
|
||||
|---|---|---|
|
||||
| `identity` | Identity Service (.NET) | tenants, users, auth, plans, payments, gamification |
|
||||
| `content` | Content Service (.NET) | templates, scenes, presets, blogs, CMS |
|
||||
| `studio` | Studio Service (.NET) | user's saved projects + audio (music/voiceover/sfx) |
|
||||
| `render` | Render Orchestrator (Go) | jobs, nodes, frame jobs, snapshots, exports |
|
||||
| `file_mgr` | File Service (Go) | user files, folders, quotas, cleanup |
|
||||
| `notification` | Notification Service (Go) | in-app, push, email, SMS, telegram |
|
||||
|
||||
## Cross-schema design
|
||||
|
||||
Schemas are **loosely coupled**. Where it matters for integrity (within a
|
||||
service), FKs are used. Across services, FKs are deliberately omitted so
|
||||
services can evolve independently — referential integrity is enforced
|
||||
via service code and events.
|
||||
|
||||
### Hard FKs across schemas (intentional)
|
||||
- `identity.earned_gifts.notification_id` → `notification.notifications.id`
|
||||
|
||||
Everything else uses **soft references** (column documented but no FK).
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
`identity.tenants` is the root of multi-tenancy. The default FlatRender
|
||||
tenant has UUID `00000000-0000-0000-0000-000000000001`.
|
||||
|
||||
Every user, project, render job, file, and notification carries a
|
||||
`tenant_id`. Resellers (B2B API customers) are tenants. White-label
|
||||
branding, API keys, webhooks, and usage metering all hang off
|
||||
`identity.tenants.*`.
|
||||
|
||||
## New features (vs V1)
|
||||
|
||||
- **Multi-tenancy / Reseller API**: `identity.tenants`, `tenant_branding`,
|
||||
`tenant_api_keys`, `tenant_webhooks`, `tenant_usage_daily`
|
||||
- **Voiceover support**: `studio.saved_projects.voiceover_*`,
|
||||
`render.render_jobs.has_voiceover`
|
||||
- **Per-track volume**: `music_volume`, `sfx_volume`, `voiceover_volume`
|
||||
- **Scene snapshots**: `render.snapshots` with cache key
|
||||
- **AE crash tracking**: `render.node_crashes` + auto-recovery
|
||||
- **Frame repair jobs**: `render.frame_repair_jobs`
|
||||
- **AEP local cache on nodes**: `render.node_template_cache` (LRU)
|
||||
- **SVG color previews**: `content.template_svg_previews` (drop image → traced SVG)
|
||||
- **PWA push subscriptions**: `identity.push_subscriptions`
|
||||
- **MFA**: `identity.mfa_factors`
|
||||
- **Multipart uploads**: `file_mgr.upload_sessions`
|
||||
- **Cleanup scheduler**: `file_mgr.cleanup_schedules`
|
||||
- **Per-user / per-channel notification preferences**:
|
||||
`notification.notification_preferences`
|
||||
|
||||
## Partitioning
|
||||
|
||||
Time-series tables are partitioned monthly (initial partition for
|
||||
2026-01 created; ops creates new ones via cron):
|
||||
|
||||
- `identity.tenant_api_request_logs`
|
||||
- `render.node_health_logs`
|
||||
|
||||
## Service user grants
|
||||
|
||||
Each microservice connects with its own DB role limited to its schema.
|
||||
See top of `00_setup.sql` for the recipe.
|
||||
@@ -0,0 +1,51 @@
|
||||
-- =====================================================================
|
||||
-- FlatRender V2 — Database Setup
|
||||
-- Single PostgreSQL database with per-service schemas
|
||||
-- =====================================================================
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text (emails)
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- fuzzy text search
|
||||
|
||||
-- =====================================================================
|
||||
-- Schemas (one per microservice)
|
||||
-- =====================================================================
|
||||
CREATE SCHEMA IF NOT EXISTS identity;
|
||||
CREATE SCHEMA IF NOT EXISTS content;
|
||||
CREATE SCHEMA IF NOT EXISTS studio;
|
||||
CREATE SCHEMA IF NOT EXISTS render;
|
||||
CREATE SCHEMA IF NOT EXISTS file_mgr;
|
||||
CREATE SCHEMA IF NOT EXISTS notification;
|
||||
|
||||
-- =====================================================================
|
||||
-- Service users (each microservice connects with limited grants)
|
||||
-- =====================================================================
|
||||
-- Run separately by ops:
|
||||
-- CREATE USER svc_identity WITH PASSWORD '...';
|
||||
-- CREATE USER svc_content WITH PASSWORD '...';
|
||||
-- CREATE USER svc_studio WITH PASSWORD '...';
|
||||
-- CREATE USER svc_render WITH PASSWORD '...';
|
||||
-- CREATE USER svc_file WITH PASSWORD '...';
|
||||
-- CREATE USER svc_notification WITH PASSWORD '...';
|
||||
-- GRANT ALL ON SCHEMA identity TO svc_identity;
|
||||
-- GRANT ALL ON SCHEMA content TO svc_content;
|
||||
-- ... etc.
|
||||
-- Read-only cross-schema grants where needed (defined per service)
|
||||
|
||||
-- =====================================================================
|
||||
-- Common helper: auto-update updated_at on row update
|
||||
-- =====================================================================
|
||||
CREATE OR REPLACE FUNCTION public.tg_set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- =====================================================================
|
||||
-- Common helper: soft-delete check (used in policies/views later)
|
||||
-- =====================================================================
|
||||
-- Convention: every soft-deletable table has `deleted_at TIMESTAMPTZ NULL`
|
||||
-- Active rows: WHERE deleted_at IS NULL
|
||||
@@ -0,0 +1,352 @@
|
||||
-- =====================================================================
|
||||
-- IDENTITY SCHEMA — Part 1: Tenants (Multi-tenancy / Reseller API)
|
||||
-- =====================================================================
|
||||
-- Every user, project, render, export belongs to a tenant.
|
||||
-- FlatRender's own customers belong to the DEFAULT tenant.
|
||||
-- Resellers (B2B) are their own tenants with their own users.
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO identity, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenants — companies / resellers (and FlatRender itself = default)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE tenant_status AS ENUM ('Active','Trial','Suspended','Cancelled');
|
||||
CREATE TYPE tenant_kind AS ENUM ('Internal','Reseller','Enterprise');
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug CITEXT UNIQUE NOT NULL, -- 'flatrender', 'acme', ...
|
||||
name TEXT NOT NULL,
|
||||
kind tenant_kind NOT NULL DEFAULT 'Reseller',
|
||||
status tenant_status NOT NULL DEFAULT 'Trial',
|
||||
|
||||
-- Domains
|
||||
custom_domain CITEXT UNIQUE, -- videos.acme.com
|
||||
domain_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
allowed_origins TEXT[] NOT NULL DEFAULT '{}', -- CORS
|
||||
|
||||
-- Contact
|
||||
contact_name TEXT,
|
||||
contact_email CITEXT,
|
||||
contact_phone TEXT,
|
||||
billing_email CITEXT,
|
||||
|
||||
-- Limits (overrideable from default plan)
|
||||
max_users INT, -- NULL = unlimited
|
||||
max_storage_gb INT,
|
||||
monthly_render_qty INT,
|
||||
monthly_render_sec INT,
|
||||
|
||||
-- Lifecycle
|
||||
trial_ends_at TIMESTAMPTZ,
|
||||
suspended_at TIMESTAMPTZ,
|
||||
suspension_reason TEXT,
|
||||
|
||||
-- Metadata
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_status ON tenants(status) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_tenants_updated_at
|
||||
BEFORE UPDATE ON tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Seed the default (internal) FlatRender tenant
|
||||
INSERT INTO tenants (id, slug, name, kind, status)
|
||||
VALUES (
|
||||
'00000000-0000-0000-0000-000000000001',
|
||||
'flatrender',
|
||||
'FlatRender',
|
||||
'Internal',
|
||||
'Active'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_branding — white-label appearance
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_branding (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Identity
|
||||
display_name TEXT,
|
||||
logo_url TEXT,
|
||||
logo_dark_url TEXT,
|
||||
favicon_url TEXT,
|
||||
og_image_url TEXT,
|
||||
|
||||
-- Theme
|
||||
primary_color TEXT NOT NULL DEFAULT '#3B82F6',
|
||||
secondary_color TEXT NOT NULL DEFAULT '#8B5CF6',
|
||||
accent_color TEXT,
|
||||
background_color TEXT,
|
||||
font_family TEXT,
|
||||
|
||||
-- Email
|
||||
email_from_name TEXT,
|
||||
email_from_address CITEXT,
|
||||
email_reply_to CITEXT,
|
||||
email_footer_html TEXT,
|
||||
|
||||
-- Links
|
||||
support_url TEXT,
|
||||
terms_url TEXT,
|
||||
privacy_url TEXT,
|
||||
|
||||
-- Studio embed
|
||||
embed_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
embed_allowed_hosts TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Custom CSS for advanced clients (sanitized)
|
||||
custom_css TEXT,
|
||||
|
||||
-- Watermark for free renders (reseller can override)
|
||||
watermark_text TEXT,
|
||||
watermark_image_url TEXT,
|
||||
watermark_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TRIGGER tg_tenant_branding_updated_at
|
||||
BEFORE UPDATE ON tenant_branding
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_settings — feature flags + integration config
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_settings (
|
||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
-- Locale
|
||||
default_locale TEXT NOT NULL DEFAULT 'fa',
|
||||
supported_locales TEXT[] NOT NULL DEFAULT '{fa,en}',
|
||||
default_currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
|
||||
-- Payments allowed for this tenant's users
|
||||
payment_gateways TEXT[] NOT NULL DEFAULT '{ZarinPal}', -- {ZarinPal,IdPay,Bazaar,Stripe}
|
||||
|
||||
-- Render
|
||||
max_resolution TEXT NOT NULL DEFAULT 'FullHD', -- HD/FullHD/FourK
|
||||
max_duration_sec INT NOT NULL DEFAULT 600,
|
||||
allow_4k BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
allow_voiceover BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
allow_music_visualizer BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
allow_mockup BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Webhooks
|
||||
webhook_signing_secret TEXT,
|
||||
|
||||
-- Feature flags
|
||||
features JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TRIGGER tg_tenant_settings_updated_at
|
||||
BEFORE UPDATE ON tenant_settings
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_api_keys — server-to-server API access
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE api_key_environment AS ENUM ('Live','Test');
|
||||
|
||||
CREATE TABLE tenant_api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL, -- "Production server"
|
||||
environment api_key_environment NOT NULL DEFAULT 'Live',
|
||||
|
||||
-- Storage
|
||||
key_prefix TEXT NOT NULL, -- "fr_live_abc..." (first 12 chars visible)
|
||||
key_hash TEXT NOT NULL, -- SHA-256 of full key
|
||||
last4 TEXT NOT NULL, -- last 4 chars for UI
|
||||
|
||||
-- Permissions
|
||||
scopes TEXT[] NOT NULL DEFAULT '{}', -- ["renders:create","projects:read",...]
|
||||
allowed_ips INET[], -- NULL = any
|
||||
rate_limit_rpm INT NOT NULL DEFAULT 600,
|
||||
|
||||
-- Lifecycle
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_reason TEXT,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
last_used_ip INET,
|
||||
usage_count BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
created_by_user_id UUID, -- FK added after users table exists
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_api_keys_hash ON tenant_api_keys(key_hash);
|
||||
CREATE INDEX idx_api_keys_tenant ON tenant_api_keys(tenant_id) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_api_keys_prefix ON tenant_api_keys(key_prefix);
|
||||
|
||||
CREATE TRIGGER tg_tenant_api_keys_updated_at
|
||||
BEFORE UPDATE ON tenant_api_keys
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_webhooks — outbound events to reseller systems
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
events TEXT[] NOT NULL, -- ["render.completed","render.failed",...]
|
||||
secret TEXT NOT NULL, -- HMAC signing secret
|
||||
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_triggered_at TIMESTAMPTZ,
|
||||
last_status_code INT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
disabled_at TIMESTAMPTZ, -- auto-disable after N failures
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhooks_tenant_active ON tenant_webhooks(tenant_id) WHERE is_active = TRUE;
|
||||
|
||||
CREATE TRIGGER tg_tenant_webhooks_updated_at
|
||||
BEFORE UPDATE ON tenant_webhooks
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_webhook_deliveries — log of attempts
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_webhook_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id UUID NOT NULL REFERENCES tenant_webhooks(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL, -- denormalized for partitioning
|
||||
|
||||
event_type TEXT NOT NULL,
|
||||
event_id UUID NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
|
||||
request_url TEXT NOT NULL,
|
||||
response_status INT,
|
||||
response_body TEXT,
|
||||
duration_ms INT,
|
||||
|
||||
attempt INT NOT NULL DEFAULT 1,
|
||||
succeeded BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
next_retry_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
|
||||
delivered_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhook_deliveries_webhook ON tenant_webhook_deliveries(webhook_id, created_at DESC);
|
||||
CREATE INDEX idx_webhook_deliveries_pending ON tenant_webhook_deliveries(next_retry_at) WHERE succeeded = FALSE AND next_retry_at IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_usage_daily — aggregate metering for billing the reseller
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_usage_daily (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
usage_date DATE NOT NULL,
|
||||
|
||||
-- Render
|
||||
renders_started INT NOT NULL DEFAULT 0,
|
||||
renders_completed INT NOT NULL DEFAULT 0,
|
||||
renders_failed INT NOT NULL DEFAULT 0,
|
||||
render_seconds BIGINT NOT NULL DEFAULT 0, -- output duration sum
|
||||
render_compute_sec BIGINT NOT NULL DEFAULT 0, -- node compute time
|
||||
|
||||
-- Storage
|
||||
storage_bytes BIGINT NOT NULL DEFAULT 0, -- snapshot at end of day
|
||||
|
||||
-- API
|
||||
api_calls BIGINT NOT NULL DEFAULT 0,
|
||||
api_4xx BIGINT NOT NULL DEFAULT 0,
|
||||
api_5xx BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Users
|
||||
active_users INT NOT NULL DEFAULT 0, -- DAU
|
||||
new_users INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Billing
|
||||
amount_billed NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||
billing_currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
billing_status TEXT NOT NULL DEFAULT 'Pending', -- Pending/Invoiced/Paid
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (tenant_id, usage_date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenant_usage_tenant_date ON tenant_usage_daily(tenant_id, usage_date DESC);
|
||||
|
||||
CREATE TRIGGER tg_tenant_usage_updated_at
|
||||
BEFORE UPDATE ON tenant_usage_daily
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_api_request_logs — per-call audit (partitioned by month)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_api_request_logs (
|
||||
id BIGSERIAL,
|
||||
tenant_id UUID NOT NULL,
|
||||
api_key_id UUID,
|
||||
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
status_code INT NOT NULL,
|
||||
duration_ms INT NOT NULL,
|
||||
|
||||
request_id UUID NOT NULL,
|
||||
user_id UUID,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
|
||||
error_message TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
PRIMARY KEY (id, created_at)
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE INDEX idx_api_logs_tenant ON tenant_api_request_logs(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_api_logs_key ON tenant_api_request_logs(api_key_id, created_at DESC);
|
||||
|
||||
-- Initial partition (ops creates monthly going forward)
|
||||
CREATE TABLE tenant_api_request_logs_y2026m01
|
||||
PARTITION OF tenant_api_request_logs
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tenant_domain_verifications — DNS / file verification
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE tenant_domain_verifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
domain CITEXT NOT NULL,
|
||||
method TEXT NOT NULL, -- 'DNS_TXT' or 'HTTP_FILE'
|
||||
challenge_token TEXT NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
verified_at TIMESTAMPTZ,
|
||||
last_checked_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_domain_verif_tenant ON tenant_domain_verifications(tenant_id);
|
||||
@@ -0,0 +1,227 @@
|
||||
-- =====================================================================
|
||||
-- IDENTITY SCHEMA — Part 2: Users, Auth, Sessions
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO identity, public;
|
||||
|
||||
CREATE TYPE register_mode AS ENUM ('Email','Mobile','Google','Telegram','SSO','Reseller');
|
||||
CREATE TYPE gender_kind AS ENUM ('Male','Female','Other','PreferNotToSay');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- users
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
|
||||
|
||||
-- Auth
|
||||
email CITEXT,
|
||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
|
||||
phone_number TEXT,
|
||||
phone_country_code TEXT,
|
||||
phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
phone_verified_at TIMESTAMPTZ,
|
||||
|
||||
password_hash TEXT, -- bcrypt; NULL for OAuth-only
|
||||
password_set_at TIMESTAMPTZ,
|
||||
last_password_reset_date TIMESTAMPTZ,
|
||||
|
||||
register_mode register_mode NOT NULL DEFAULT 'Email',
|
||||
external_provider TEXT, -- google_oauth_subject etc.
|
||||
external_provider_id TEXT,
|
||||
|
||||
-- Profile
|
||||
full_name TEXT,
|
||||
avatar_url TEXT,
|
||||
birth_date DATE,
|
||||
gender gender_kind,
|
||||
national_code TEXT, -- Iran-specific
|
||||
country_code TEXT,
|
||||
company_name TEXT,
|
||||
website_name TEXT,
|
||||
slogan TEXT,
|
||||
about_me TEXT,
|
||||
method_of_introduction TEXT,
|
||||
|
||||
-- Balances (cents/rial; use BIGINT to avoid float)
|
||||
balance_minor BIGINT NOT NULL DEFAULT 0,
|
||||
affiliate_balance_minor BIGINT NOT NULL DEFAULT 0,
|
||||
affiliate_owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
profit_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
|
||||
-- Gamification (kept lean)
|
||||
loyalty_score INT NOT NULL DEFAULT 0,
|
||||
purple_point INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Render quotas (computed from plan + bonuses)
|
||||
daily_remain_render_count INT NOT NULL DEFAULT 0,
|
||||
max_daily_render_count INT NOT NULL DEFAULT 0,
|
||||
parallel_rendering_ceiling INT NOT NULL DEFAULT 1,
|
||||
user_daily_free_charge_sec INT NOT NULL DEFAULT 0,
|
||||
daily_free_charge_reset_date TIMESTAMPTZ,
|
||||
max_preview_duration_sec INT NOT NULL DEFAULT 30,
|
||||
force_render_queue BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
remove_watermark_service BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Telegram (legacy but kept)
|
||||
telegram_id TEXT,
|
||||
telegram_token TEXT,
|
||||
telegram_token_expire_date TIMESTAMPTZ,
|
||||
telegram_tell_me BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
telegram_reset_date TIMESTAMPTZ,
|
||||
user_telegram_charge INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Comms preferences
|
||||
email_tell_me BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sms_tell_me BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
push_tell_me BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Storage
|
||||
storage_endpoint TEXT,
|
||||
used_storage_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Status
|
||||
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_tenant_admin BOOLEAN NOT NULL DEFAULT FALSE, -- admin within a tenant
|
||||
ban_account BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ban_reason TEXT,
|
||||
unblock_date TIMESTAMPTZ,
|
||||
|
||||
-- Activity
|
||||
last_active_date TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
last_login_ip INET,
|
||||
registered_with_mobile_app BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
register_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
-- Misc
|
||||
cid TEXT, -- legacy
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
-- Uniqueness scoped to tenant (so two tenants can have user@x.com independently)
|
||||
CONSTRAINT uq_users_tenant_email UNIQUE (tenant_id, email),
|
||||
CONSTRAINT uq_users_tenant_phone UNIQUE (tenant_id, phone_number),
|
||||
CONSTRAINT uq_users_external UNIQUE (external_provider, external_provider_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_tenant ON users(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_email ON users(email) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_users_affiliate ON users(affiliate_owner_id) WHERE affiliate_owner_id IS NOT NULL;
|
||||
CREATE INDEX idx_users_last_active ON users(last_active_date DESC);
|
||||
CREATE INDEX idx_users_fullname_trgm ON users USING gin (full_name gin_trgm_ops);
|
||||
|
||||
CREATE TRIGGER tg_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Now fix FK from tenant_api_keys.created_by_user_id
|
||||
ALTER TABLE tenant_api_keys
|
||||
ADD CONSTRAINT fk_api_keys_creator
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_sessions — JWT refresh tokens / device tracking
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE user_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
refresh_token_hash TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
device_name TEXT,
|
||||
user_agent TEXT,
|
||||
ip_address INET,
|
||||
|
||||
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_sessions_token ON user_sessions(refresh_token_hash);
|
||||
CREATE INDEX idx_sessions_user ON user_sessions(user_id) WHERE revoked_at IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- confirmation_tokens — email/phone verify, password reset, MFA
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE token_purpose AS ENUM (
|
||||
'EmailVerification','PhoneVerification',
|
||||
'PasswordReset','MfaSetup','Login','EmailChange'
|
||||
);
|
||||
|
||||
CREATE TABLE confirmation_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
purpose token_purpose NOT NULL,
|
||||
|
||||
identifier CITEXT NOT NULL, -- email or phone being verified
|
||||
next_identifier CITEXT, -- for email change
|
||||
|
||||
token_hash TEXT NOT NULL,
|
||||
code TEXT, -- 6-digit OTP (hashed in token_hash)
|
||||
|
||||
is_consumed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
try_count INT NOT NULL DEFAULT 0,
|
||||
max_tries INT NOT NULL DEFAULT 5,
|
||||
|
||||
request_ip INET,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conf_tokens_user ON confirmation_tokens(user_id, purpose) WHERE is_consumed = FALSE;
|
||||
CREATE INDEX idx_conf_tokens_lookup ON confirmation_tokens(token_hash) WHERE is_consumed = FALSE;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- push_subscriptions — PWA Web Push
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE push_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
endpoint TEXT NOT NULL,
|
||||
p256dh_key TEXT NOT NULL,
|
||||
auth_key TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
failure_count INT NOT NULL DEFAULT 0,
|
||||
last_failure_at TIMESTAMPTZ,
|
||||
last_failure_status INT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (user_id, endpoint)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_push_subs_user_active ON push_subscriptions(user_id) WHERE is_active = TRUE;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- mfa_factors — TOTP, SMS, recovery codes
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE mfa_factor_type AS ENUM ('TOTP','SMS','Email','RecoveryCode');
|
||||
|
||||
CREATE TABLE mfa_factors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
factor_type mfa_factor_type NOT NULL,
|
||||
secret_encrypted TEXT,
|
||||
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
label TEXT,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mfa_user ON mfa_factors(user_id) WHERE is_verified = TRUE;
|
||||
@@ -0,0 +1,314 @@
|
||||
-- =====================================================================
|
||||
-- IDENTITY SCHEMA — Part 3: Plans, Subscriptions, Payments, Discounts
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO identity, public;
|
||||
|
||||
CREATE TYPE plan_scope AS ENUM ('User','Tenant');
|
||||
CREATE TYPE billing_period AS ENUM ('Monthly','Quarterly','SemiAnnual','Annual','Lifetime','OneTime');
|
||||
CREATE TYPE payment_gateway AS ENUM ('ZarinPal','IdPay','Bazaar','Stripe','Balance','Manual');
|
||||
CREATE TYPE payment_status AS ENUM ('Pending','Succeeded','Failed','Refunded','Cancelled');
|
||||
CREATE TYPE payment_action AS ENUM ('PlanPurchase','BalanceCharge','ProjectRender','UserProject','StorageUpgrade','Other');
|
||||
CREATE TYPE discount_kind AS ENUM ('Percentage','FixedAmount','FreeMonths','RenderCredits');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- plans — subscription tiers (FlatRender or tenant-defined)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
-- NULL tenant_id = global plan available to all tenants
|
||||
|
||||
scope plan_scope NOT NULL DEFAULT 'User',
|
||||
code TEXT NOT NULL, -- 'pro_monthly', 'business_annual'
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- Pricing
|
||||
price_minor BIGINT NOT NULL DEFAULT 0,
|
||||
before_price_minor BIGINT, -- shown crossed-out
|
||||
currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
discount_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
|
||||
billing_period billing_period NOT NULL DEFAULT 'Monthly',
|
||||
months_duration INT, -- for non-monthly
|
||||
|
||||
-- Quotas
|
||||
seconds_charge INT NOT NULL DEFAULT 0, -- render seconds included
|
||||
monthly_renders_quota INT, -- NULL = unlimited
|
||||
storage_gb INT NOT NULL DEFAULT 1,
|
||||
parallel_renders INT NOT NULL DEFAULT 1,
|
||||
max_resolution TEXT NOT NULL DEFAULT 'FullHD',
|
||||
min_video_length_sec INT NOT NULL DEFAULT 0,
|
||||
render_speed_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
||||
|
||||
-- UI
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
icon TEXT,
|
||||
cover TEXT,
|
||||
loyalty_mark TEXT,
|
||||
color TEXT,
|
||||
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Feature flags carried by this plan
|
||||
features JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Lifecycle
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
available_from TIMESTAMPTZ,
|
||||
available_until TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT uq_plans_tenant_code UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_plans_tenant_active ON plans(tenant_id) WHERE is_active = TRUE AND deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_plans_updated_at
|
||||
BEFORE UPDATE ON plans
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_plans — active subscriptions
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE user_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
plan_id UUID NOT NULL REFERENCES plans(id),
|
||||
|
||||
-- Snapshot at purchase time
|
||||
plan_code TEXT NOT NULL,
|
||||
plan_name TEXT NOT NULL,
|
||||
price_minor_paid BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
|
||||
-- Quota state
|
||||
initial_seconds_charge INT NOT NULL,
|
||||
remain_charge_sec INT NOT NULL,
|
||||
added_charge_from_past_plan INT NOT NULL DEFAULT 0,
|
||||
monthly_renders_used INT NOT NULL DEFAULT 0,
|
||||
monthly_renders_reset_at TIMESTAMPTZ,
|
||||
|
||||
-- Lifecycle
|
||||
register_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
cancelled_at TIMESTAMPTZ,
|
||||
cancel_reason TEXT,
|
||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Reference to payment
|
||||
payment_id UUID,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_plans_active ON user_plans(user_id) WHERE cancelled_at IS NULL;
|
||||
CREATE INDEX idx_user_plans_expire ON user_plans(expires_at) WHERE cancelled_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_user_plans_updated_at
|
||||
BEFORE UPDATE ON user_plans
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- discounts — coupons / affiliate codes
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE discounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
code CITEXT NOT NULL,
|
||||
kind discount_kind NOT NULL,
|
||||
value NUMERIC(14,2) NOT NULL, -- percent or amount
|
||||
|
||||
-- Affiliate split
|
||||
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
owner_profit_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
|
||||
only_owner BOOLEAN NOT NULL DEFAULT FALSE, -- usable only by owner
|
||||
|
||||
-- Constraints
|
||||
max_use_count INT, -- NULL = unlimited
|
||||
used_count INT NOT NULL DEFAULT 0,
|
||||
min_purchase_minor BIGINT NOT NULL DEFAULT 0,
|
||||
applies_to_plan_ids UUID[],
|
||||
|
||||
starts_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT uq_discounts_tenant_code UNIQUE (tenant_id, code)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_discounts_active ON discounts(tenant_id) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_discounts_owner ON discounts(owner_user_id) WHERE owner_user_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_discounts_updated_at
|
||||
BEFORE UPDATE ON discounts
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- used_discounts — usage log per user
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE used_discounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
discount_id UUID NOT NULL REFERENCES discounts(id) ON DELETE RESTRICT,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
payment_id UUID,
|
||||
code CITEXT NOT NULL,
|
||||
amount_discounted_minor BIGINT NOT NULL,
|
||||
use_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_used_discounts_user ON used_discounts(user_id, use_date DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- payments
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
|
||||
gateway payment_gateway NOT NULL,
|
||||
status payment_status NOT NULL DEFAULT 'Pending',
|
||||
action payment_action NOT NULL,
|
||||
|
||||
-- Amounts
|
||||
amount_minor BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
balance_reducer_minor BIGINT NOT NULL DEFAULT 0, -- portion paid from balance
|
||||
discount_value_minor BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Gateway refs
|
||||
gateway_token TEXT,
|
||||
gateway_order_id TEXT,
|
||||
gateway_track_id TEXT,
|
||||
gateway_response JSONB,
|
||||
card_last4 TEXT,
|
||||
card_hash TEXT,
|
||||
|
||||
-- Description
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
|
||||
-- Affiliate
|
||||
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
affiliate_profit_minor BIGINT NOT NULL DEFAULT 0,
|
||||
after_pay_score NUMERIC(8,2) NOT NULL DEFAULT 0,
|
||||
|
||||
-- Polymorphic product reference
|
||||
used_discount_id UUID REFERENCES used_discounts(id),
|
||||
plan_id UUID REFERENCES plans(id),
|
||||
user_project_id UUID, -- FK added later if user_projects keeps existing
|
||||
render_job_id UUID,
|
||||
product_id UUID,
|
||||
|
||||
-- Lifecycle
|
||||
confirmed_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
failure_reason TEXT,
|
||||
refunded_at TIMESTAMPTZ,
|
||||
refund_amount_minor BIGINT,
|
||||
refund_reason TEXT,
|
||||
|
||||
-- Checkout/receipt
|
||||
checkouted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
checkout_date TIMESTAMPTZ,
|
||||
checkout_recipe TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_payments_user ON payments(user_id, created_at DESC);
|
||||
CREATE INDEX idx_payments_tenant ON payments(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_payments_status ON payments(status) WHERE status IN ('Pending','Failed');
|
||||
CREATE INDEX idx_payments_track ON payments(gateway, gateway_track_id) WHERE gateway_track_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_payments_updated_at
|
||||
BEFORE UPDATE ON payments
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- Now wire up the FKs that were dangling
|
||||
ALTER TABLE used_discounts
|
||||
ADD CONSTRAINT fk_used_disc_payment
|
||||
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE user_plans
|
||||
ADD CONSTRAINT fk_user_plans_payment
|
||||
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- checkouts — receipts / affiliate payouts log
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE checkouts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
affiliate_owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
amount_minor BIGINT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'IRR',
|
||||
card_number_last4 TEXT,
|
||||
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
paid_at TIMESTAMPTZ,
|
||||
recipe TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_checkouts_user ON checkouts(user_id, requested_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_projects — freelance custom-template requests
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE user_project_status AS ENUM (
|
||||
'Draft','Submitted','Quoted','InProgress','Review','Completed','Cancelled'
|
||||
);
|
||||
|
||||
CREATE TABLE user_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
graphist_description TEXT,
|
||||
attachment_path TEXT,
|
||||
|
||||
price_minor BIGINT,
|
||||
user_suggested_price_minor BIGINT,
|
||||
status user_project_status NOT NULL DEFAULT 'Draft',
|
||||
|
||||
user_max_requested_day_to_create INT,
|
||||
user_graphist_max_requested_day_to_create INT,
|
||||
|
||||
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
end_time TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_user_projects_user ON user_projects(user_id, status);
|
||||
|
||||
CREATE TRIGGER tg_user_projects_updated_at
|
||||
BEFORE UPDATE ON user_projects
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT fk_payments_user_project
|
||||
FOREIGN KEY (user_project_id) REFERENCES user_projects(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,149 @@
|
||||
-- =====================================================================
|
||||
-- IDENTITY SCHEMA — Part 4: Gamification (simplified)
|
||||
-- =====================================================================
|
||||
-- Quests, gifts, loyalty — leaner than V1 but still functional.
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO identity, public;
|
||||
|
||||
CREATE TYPE quest_type AS ENUM ('OneTime','Daily','Weekly','Onboarding','Milestone');
|
||||
CREATE TYPE prize_type AS ENUM ('Balance','RenderSeconds','LoyaltyPoints','StorageGB','Plan','Discount');
|
||||
CREATE TYPE gift_type AS ENUM ('Bonus','Referral','Compensation','Promotion','Achievement');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- quests
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE quests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, -- NULL = global
|
||||
|
||||
title TEXT NOT NULL,
|
||||
challenge TEXT,
|
||||
why TEXT,
|
||||
hint TEXT,
|
||||
aphorism TEXT,
|
||||
icon TEXT,
|
||||
|
||||
quest_type quest_type NOT NULL,
|
||||
target_event TEXT NOT NULL, -- 'user.registered','project.created',...
|
||||
target_count INT NOT NULL DEFAULT 1, -- how many times event must fire
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
prize_type prize_type NOT NULL,
|
||||
prize_amount BIGINT NOT NULL, -- minor units or seconds/points
|
||||
|
||||
level_limit INT, -- minimum loyalty level
|
||||
start_url TEXT,
|
||||
post_action_name TEXT,
|
||||
order_value INT NOT NULL DEFAULT 0,
|
||||
|
||||
starts_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_quests_active ON quests(quest_type, is_active);
|
||||
|
||||
CREATE TRIGGER tg_quests_updated_at
|
||||
BEFORE UPDATE ON quests
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_quest_progress — incremental tracking
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE user_quest_progress (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
|
||||
|
||||
current_count INT NOT NULL DEFAULT 0,
|
||||
text_value TEXT, -- for input-based quests
|
||||
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
completed_at TIMESTAMPTZ,
|
||||
prize_claimed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
prize_claimed_at TIMESTAMPTZ,
|
||||
|
||||
period_start DATE, -- for Daily/Weekly resets
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (user_id, quest_id, period_start)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_quest_prog_user_open ON user_quest_progress(user_id) WHERE is_completed = FALSE;
|
||||
|
||||
CREATE TRIGGER tg_quest_prog_updated_at
|
||||
BEFORE UPDATE ON user_quest_progress
|
||||
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- gifts — admin-issued bonuses
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE gifts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
icon TEXT,
|
||||
gift_type gift_type NOT NULL,
|
||||
prize_type prize_type NOT NULL,
|
||||
value BIGINT NOT NULL,
|
||||
unit TEXT, -- 'seconds','IRR','points',...
|
||||
|
||||
assigned_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- earned_gifts — issued to user (consumed via used_gifts)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE earned_gifts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
gift_id UUID NOT NULL REFERENCES gifts(id) ON DELETE RESTRICT,
|
||||
notification_id UUID, -- FK added later
|
||||
source TEXT, -- 'quest','admin','referral','plan'
|
||||
source_ref UUID, -- e.g. quest_id
|
||||
|
||||
earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
used_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_earned_gifts_user ON earned_gifts(user_id) WHERE is_used = FALSE;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- used_gifts — claim log
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE used_gifts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
earned_gift_id UUID NOT NULL REFERENCES earned_gifts(id) ON DELETE RESTRICT,
|
||||
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_used_gifts_user ON used_gifts(user_id, used_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- avatars — preset avatar library
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE avatars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
-- =====================================================================
|
||||
-- CONTENT SCHEMA — Part 1: Taxonomy & Assets (categories, tags, fonts, music)
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO content, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- categories — hierarchical
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
parent_id UUID REFERENCES categories(id) ON DELETE SET NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
slug CITEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
icon TEXT,
|
||||
|
||||
-- SEO
|
||||
meta_title TEXT,
|
||||
meta_description TEXT,
|
||||
meta_keywords TEXT,
|
||||
bot_follow BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||
CREATE INDEX idx_categories_active ON categories(is_active) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_categories_updated_at
|
||||
BEFORE UPDATE ON categories FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- container_categories — many-to-many
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Will be created after project_containers table
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- tags
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE choose_mode AS ENUM ('FIX','FLEXIBLE','MockUp','MusicVisualizer','VoiceOver');
|
||||
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
latin_name TEXT,
|
||||
slug CITEXT NOT NULL UNIQUE,
|
||||
applies_to_mode choose_mode,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tags_active ON tags(is_active) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_tags_updated_at
|
||||
BEFORE UPDATE ON tags FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- fonts
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE fonts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL, -- display name
|
||||
original_name TEXT, -- as registered in AE
|
||||
system_name TEXT, -- exact OS family name
|
||||
family TEXT,
|
||||
weight INT, -- 100-900
|
||||
style TEXT, -- 'normal' | 'italic'
|
||||
direction TEXT NOT NULL DEFAULT 'LTR', -- LTR/RTL/Auto
|
||||
file_url TEXT, -- .ttf/.otf URL
|
||||
sample_image_url TEXT,
|
||||
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
installed_on_nodes BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fonts_active ON fonts(is_active) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_fonts_updated_at
|
||||
BEFORE UPDATE ON fonts FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- music_tracks
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE music_tracks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
caption TEXT,
|
||||
keywords TEXT,
|
||||
url TEXT NOT NULL,
|
||||
waveform_data JSONB, -- precomputed visualization
|
||||
duration_sec NUMERIC(8,2) NOT NULL,
|
||||
bpm INT,
|
||||
genre TEXT,
|
||||
mood TEXT,
|
||||
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_music_active ON music_tracks(is_active) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_music_genre ON music_tracks(genre);
|
||||
|
||||
CREATE TRIGGER tg_music_updated_at
|
||||
BEFORE UPDATE ON music_tracks FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- project_servers — render server configs (multi-region)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE project_servers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
region TEXT NOT NULL, -- 'tehran','frankfurt',...
|
||||
ip INET,
|
||||
physical_path_output TEXT,
|
||||
default_project_address TEXT,
|
||||
render_output_location TEXT,
|
||||
pre_need_folder_address TEXT,
|
||||
minio_endpoint TEXT,
|
||||
minio_bucket_templates TEXT,
|
||||
minio_bucket_outputs TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_servers_region ON project_servers(region) WHERE is_active = TRUE;
|
||||
|
||||
CREATE TRIGGER tg_project_servers_updated_at
|
||||
BEFORE UPDATE ON project_servers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- admin_files — admin-uploaded resources
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE admin_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT,
|
||||
url TEXT NOT NULL,
|
||||
thumbnail_url TEXT,
|
||||
file_type TEXT,
|
||||
size_bytes BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,143 @@
|
||||
-- =====================================================================
|
||||
-- CONTENT SCHEMA — Part 2: Project Containers & Projects (Templates)
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO content, public;
|
||||
|
||||
-- Tenants can mark projects as private (only their users see them)
|
||||
-- or use the global FlatRender catalog.
|
||||
|
||||
CREATE TYPE resolution_kind AS ENUM ('HD','FullHD','TwoK','FourK');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- project_containers — the "product" (template pack)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE project_containers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID, -- NULL = global
|
||||
-- FK to identity.tenants is enforced via service code (cross-schema FK kept loose)
|
||||
|
||||
slug CITEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
keywords TEXT,
|
||||
news_text TEXT,
|
||||
|
||||
-- Media
|
||||
image TEXT,
|
||||
demo TEXT,
|
||||
full_demo TEXT,
|
||||
mini_demo TEXT,
|
||||
demo_script_tag TEXT,
|
||||
|
||||
-- Modes & classifications
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_premium BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_mockup BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
primary_mode choose_mode NOT NULL DEFAULT 'FLEXIBLE',
|
||||
|
||||
-- Stats (denormalized for speed)
|
||||
rate_avg NUMERIC(3,2),
|
||||
rate_count INT NOT NULL DEFAULT 0,
|
||||
view_count BIGINT NOT NULL DEFAULT 0,
|
||||
use_count BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
sort_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_containers_published ON project_containers(is_published, sort_date DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_containers_tenant ON project_containers(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_containers_name_trgm ON project_containers USING gin (name gin_trgm_ops);
|
||||
|
||||
CREATE TRIGGER tg_containers_updated_at
|
||||
BEFORE UPDATE ON project_containers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- M2M: container ↔ categories
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE container_categories (
|
||||
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
|
||||
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (container_id, category_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cc_category ON container_categories(category_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- M2M: container ↔ tags
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE container_tags (
|
||||
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (container_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ct_tag ON container_tags(tag_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- projects — one aspect-ratio variant of a container
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
|
||||
project_server_id UUID REFERENCES project_servers(id),
|
||||
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
full_demo TEXT,
|
||||
demo_script_tag TEXT,
|
||||
download_link TEXT,
|
||||
|
||||
-- AEP file storage
|
||||
aep_minio_bucket TEXT,
|
||||
aep_minio_key TEXT,
|
||||
aep_file_url TEXT,
|
||||
aep_file_md5 TEXT, -- for node cache check
|
||||
aep_file_size_bytes BIGINT,
|
||||
aep_uploaded_at TIMESTAMPTZ,
|
||||
folder TEXT, -- legacy path on server
|
||||
|
||||
-- Geometry
|
||||
original_width INT NOT NULL,
|
||||
original_height INT NOT NULL,
|
||||
aspect TEXT, -- '16:9','9:16','1:1','4:5',...
|
||||
|
||||
-- Timing
|
||||
project_duration_sec NUMERIC(8,2) NOT NULL,
|
||||
min_duration_sec NUMERIC(8,2),
|
||||
max_duration_sec NUMERIC(8,2),
|
||||
free_fps INT NOT NULL DEFAULT 30,
|
||||
|
||||
-- Mode
|
||||
choose_mode choose_mode NOT NULL,
|
||||
resolution resolution_kind NOT NULL DEFAULT 'FullHD',
|
||||
vip_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
||||
render_aep_comp TEXT NOT NULL DEFAULT 'flatrender', -- main comp name
|
||||
|
||||
-- Misc (legacy artifacts to preserve)
|
||||
shared_layer_image TEXT,
|
||||
shared_colors_svg TEXT,
|
||||
shared_color_presets_svg TEXT,
|
||||
|
||||
-- Status
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_projects_container ON projects(container_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_projects_published ON projects(is_published) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_projects_aep_md5 ON projects(aep_file_md5) WHERE aep_file_md5 IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_projects_updated_at
|
||||
BEFORE UPDATE ON projects FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
@@ -0,0 +1,368 @@
|
||||
-- =====================================================================
|
||||
-- CONTENT SCHEMA — Part 3: Scenes & Editable Elements
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO content, public;
|
||||
|
||||
CREATE TYPE scene_kind AS ENUM ('Normal','Config','DesignStart','DesignEnd');
|
||||
CREATE TYPE content_element_type AS ENUM (
|
||||
'Text','TextArea','Media','Audio','Voiceover',
|
||||
'CheckBox','DropDown','Fill','Color','Number',
|
||||
'Date','Toggle','Slider','Counter','Hidden'
|
||||
);
|
||||
CREATE TYPE justify_kind AS ENUM ('LEFT_JUSTIFY','CENTER_JUSTIFY','RIGHT_JUSTIFY','FULL_JUSTIFY');
|
||||
CREATE TYPE ai_input_type AS ENUM ('None','TitleSuggest','BodySuggest','TranslateRtl','TranslateLtr','RemoveBG','UpscaleImage','TTS');
|
||||
CREATE TYPE repeat_sort_strategy AS ENUM ('Manual','Alphabetical','Numerical','InsertOrder');
|
||||
CREATE TYPE attr_value_kind AS ENUM ('fill','stroke','tracking','dropshadow');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scenes
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scenes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Identity (maps to AE comp name)
|
||||
key TEXT NOT NULL, -- matches AE comp name
|
||||
title TEXT NOT NULL,
|
||||
localized_title JSONB, -- {"fa":"...","en":"..."}
|
||||
|
||||
-- Type
|
||||
scene_type scene_kind NOT NULL DEFAULT 'Normal',
|
||||
|
||||
-- Media
|
||||
image TEXT,
|
||||
demo TEXT,
|
||||
scene_color_svg TEXT, -- SVG color preview (legacy)
|
||||
snapshot_url TEXT, -- pre-rendered representative frame
|
||||
|
||||
-- Animation flags
|
||||
generate_kf BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Timing
|
||||
default_duration_sec NUMERIC(8,2),
|
||||
min_duration_sec NUMERIC(8,2),
|
||||
max_duration_sec NUMERIC(8,2),
|
||||
overlap_at_end_sec NUMERIC(6,2) NOT NULL DEFAULT 0,
|
||||
can_handle_duration BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Customization
|
||||
manual_color_selection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
UNIQUE (project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scenes_project ON scenes(project_id, sort) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_scenes_type ON scenes(project_id, scene_type);
|
||||
|
||||
CREATE TRIGGER tg_scenes_updated_at
|
||||
BEFORE UPDATE ON scenes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- M2M: scenes ↔ categories
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_categories (
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (scene_id, category_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- repeater_items — repeating sub-blocks within a scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE repeater_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
|
||||
title TEXT NOT NULL,
|
||||
repeat_box_key TEXT NOT NULL, -- AE layer name of container
|
||||
repeat_item_key TEXT NOT NULL, -- AE layer name of item template
|
||||
|
||||
max_repeat_count INT NOT NULL DEFAULT 10,
|
||||
user_can_change_sort BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
repeat_sort_strategy repeat_sort_strategy NOT NULL DEFAULT 'Manual',
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_repeaters_scene ON repeater_items(scene_id);
|
||||
|
||||
CREATE TRIGGER tg_repeaters_updated_at
|
||||
BEFORE UPDATE ON repeater_items FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_content_elements — every editable field in a scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_content_elements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
repeater_item_id UUID REFERENCES repeater_items(id) ON DELETE CASCADE,
|
||||
|
||||
-- Identity (maps to AE frl_/frd_ layer name)
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
localized_title JSONB,
|
||||
hint TEXT,
|
||||
|
||||
type content_element_type NOT NULL,
|
||||
default_value TEXT,
|
||||
|
||||
-- Text-specific
|
||||
font_id UUID REFERENCES fonts(id),
|
||||
font_face TEXT,
|
||||
font_face_name TEXT,
|
||||
font_size INT,
|
||||
default_font_size INT,
|
||||
default_font_face TEXT,
|
||||
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
justify justify_kind NOT NULL DEFAULT 'CENTER_JUSTIFY',
|
||||
can_justify BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
position_in_container INT NOT NULL DEFAULT 0, -- 0-8 (see JSX positionMode)
|
||||
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
max_size INT, -- max char count
|
||||
direction_layer_key TEXT, -- companion frd_ for RTL
|
||||
direction_layer_value INT NOT NULL DEFAULT 0, -- 0=LTR, 1=RTL
|
||||
|
||||
-- Media-specific
|
||||
video_support BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
min_duration_sec NUMERIC(6,2),
|
||||
max_duration_sec NUMERIC(6,2),
|
||||
width INT,
|
||||
height INT,
|
||||
thumbnail TEXT,
|
||||
|
||||
-- Dropdown / mapped list
|
||||
mapped_list JSONB, -- [{"label","value"}, ...]
|
||||
counter_mode TEXT,
|
||||
|
||||
-- AI
|
||||
ai_input_type ai_input_type NOT NULL DEFAULT 'None',
|
||||
|
||||
-- Visibility / linking
|
||||
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
opacity_controller_key TEXT, -- ref another element's key
|
||||
|
||||
-- Design pattern variants (legacy DP1-4 system)
|
||||
dp1_image TEXT, dp1_title TEXT,
|
||||
dp2_image TEXT, dp2_title TEXT,
|
||||
dp3_image TEXT, dp3_title TEXT,
|
||||
dp4_image TEXT, dp4_title TEXT,
|
||||
|
||||
-- Repeater virtualization
|
||||
virtual_count INT NOT NULL DEFAULT 1,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (scene_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sce_scene ON scene_content_elements(scene_id, sort);
|
||||
CREATE INDEX idx_sce_repeater ON scene_content_elements(repeater_item_id) WHERE repeater_item_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_sce_updated_at
|
||||
BEFORE UPDATE ON scene_content_elements FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_color_elements — color zones (frd_ data layers per scene)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_color_elements (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
|
||||
element_key TEXT NOT NULL, -- matches frd_ layer name
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
attr_value attr_value_kind NOT NULL DEFAULT 'fill',
|
||||
default_color TEXT NOT NULL, -- '#RRGGBB' or 'r,g,b'
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (scene_id, element_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sce_color_scene ON scene_color_elements(scene_id, sort);
|
||||
|
||||
CREATE TRIGGER tg_sce_color_updated_at
|
||||
BEFORE UPDATE ON scene_color_elements FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_color_presets — theme presets per scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_color_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE scene_color_preset_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
preset_id UUID NOT NULL REFERENCES scene_color_presets(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sc_preset_items_preset ON scene_color_preset_items(preset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- shared_colors — global colors across project (frshare comp)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE shared_colors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
element_key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
attr_value attr_value_kind NOT NULL DEFAULT 'fill',
|
||||
default_color TEXT NOT NULL,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (project_id, element_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_colors_project ON shared_colors(project_id, sort);
|
||||
|
||||
CREATE TRIGGER tg_shared_colors_updated_at
|
||||
BEFORE UPDATE ON shared_colors FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- shared_color_presets — project-wide palette themes
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE shared_color_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE shared_color_preset_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
preset_id UUID NOT NULL REFERENCES shared_color_presets(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_preset_items_preset ON shared_color_preset_items(preset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- shared_layers — global text/media layers across all scenes
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE shared_layers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
|
||||
key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
localized_title JSONB,
|
||||
hint TEXT,
|
||||
|
||||
type content_element_type NOT NULL,
|
||||
default_value TEXT,
|
||||
|
||||
font_id UUID REFERENCES fonts(id),
|
||||
font_face TEXT,
|
||||
font_face_name TEXT,
|
||||
font_size INT,
|
||||
default_font_size INT,
|
||||
default_font_face TEXT,
|
||||
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
justify justify_kind NOT NULL DEFAULT 'CENTER_JUSTIFY',
|
||||
can_justify BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
position_in_container INT NOT NULL DEFAULT 0,
|
||||
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
max_size INT,
|
||||
direction_layer_key TEXT,
|
||||
direction_layer_value INT NOT NULL DEFAULT 0,
|
||||
|
||||
video_support BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
min_duration_sec NUMERIC(6,2),
|
||||
max_duration_sec NUMERIC(6,2),
|
||||
width INT,
|
||||
height INT,
|
||||
thumbnail TEXT,
|
||||
|
||||
mapped_list JSONB,
|
||||
counter_mode TEXT,
|
||||
ai_input_type ai_input_type NOT NULL DEFAULT 'None',
|
||||
|
||||
is_hidden BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
dp1_image TEXT, dp1_title TEXT,
|
||||
dp2_image TEXT, dp2_title TEXT,
|
||||
dp3_image TEXT, dp3_title TEXT,
|
||||
dp4_image TEXT, dp4_title TEXT,
|
||||
|
||||
virtual_count INT NOT NULL DEFAULT 1,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shared_layers_project ON shared_layers(project_id, sort);
|
||||
|
||||
CREATE TRIGGER tg_shared_layers_updated_at
|
||||
BEFORE UPDATE ON shared_layers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- template_svg_previews — NEW: drop-an-image → traced SVG for live color preview
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE template_svg_previews (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
||||
scene_id UUID REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
-- Exactly one of project_id or scene_id should be set
|
||||
|
||||
source_image_url TEXT, -- the dropped image
|
||||
svg_url TEXT NOT NULL, -- in MinIO
|
||||
thumbnail_url TEXT,
|
||||
|
||||
-- Maps each SVG <path data-color-key="frd_bg"> to a color element
|
||||
color_zones JSONB NOT NULL,
|
||||
-- Example: [{"element_key":"frd_bg","detected_color":"#1A1A2E","bbox":[x,y,w,h]}]
|
||||
|
||||
width INT,
|
||||
height INT,
|
||||
generation_method TEXT, -- 'auto','manual','ai-assisted'
|
||||
generated_by_ai BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
quality_score NUMERIC(3,2), -- 0-1 confidence
|
||||
|
||||
created_by_user_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CHECK ((project_id IS NULL) <> (scene_id IS NULL)) -- exactly one
|
||||
);
|
||||
|
||||
CREATE INDEX idx_svg_previews_project ON template_svg_previews(project_id);
|
||||
CREATE INDEX idx_svg_previews_scene ON template_svg_previews(scene_id);
|
||||
|
||||
CREATE TRIGGER tg_svg_previews_updated_at
|
||||
BEFORE UPDATE ON template_svg_previews FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
@@ -0,0 +1,160 @@
|
||||
-- =====================================================================
|
||||
-- CONTENT SCHEMA — Part 4: Characters & Preset Stories
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO content, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_characters — character elements per scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_characters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (scene_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scene_chars_scene ON scene_characters(scene_id);
|
||||
|
||||
CREATE TRIGGER tg_scene_chars_updated_at
|
||||
BEFORE UPDATE ON scene_characters FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_character_controllers — animation properties of a character
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_character_controllers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
scene_character_id UUID NOT NULL REFERENCES scene_characters(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
default_value TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scene_char_ctrl_char ON scene_character_controllers(scene_character_id);
|
||||
|
||||
CREATE TRIGGER tg_scene_char_ctrl_updated_at
|
||||
BEFORE UPDATE ON scene_character_controllers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- scene_controller_options — discrete value options per controller
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE scene_controller_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
controller_id UUID NOT NULL REFERENCES scene_character_controllers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_scene_ctrl_opt_ctrl ON scene_controller_options(controller_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- project_character_controllers — project-wide character animation defs
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE project_character_controllers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_proj_char_ctrl_project ON project_character_controllers(project_id);
|
||||
|
||||
CREATE TRIGGER tg_proj_char_ctrl_updated_at
|
||||
BEFORE UPDATE ON project_character_controllers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- project_character_controller_options
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE project_character_controller_options (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
controller_id UUID NOT NULL REFERENCES project_character_controllers(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- project_character_presets — named bundles of controller values
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE project_character_presets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
key UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_char_presets_project ON project_character_presets(project_id);
|
||||
|
||||
CREATE TRIGGER tg_char_presets_updated_at
|
||||
BEFORE UPDATE ON project_character_presets FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- preset_character_controllers — controller values inside a preset
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE preset_character_controllers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
character_preset_id UUID NOT NULL REFERENCES project_character_presets(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_preset_char_ctrl_preset ON preset_character_controllers(character_preset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- preset_stories — pre-made scene combinations
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE preset_stories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
demo TEXT,
|
||||
music_id UUID REFERENCES music_tracks(id),
|
||||
scenes_spa TEXT, -- legacy serialized SPA (kept for migration)
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_published BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_preset_stories_project ON preset_stories(project_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_preset_stories_updated_at
|
||||
BEFORE UPDATE ON preset_stories FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- preset_scenes — which scenes appear in a preset story and order
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE preset_scenes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
preset_story_id UUID NOT NULL REFERENCES preset_stories(id) ON DELETE CASCADE,
|
||||
scene_id UUID NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
default_duration_sec NUMERIC(8,2),
|
||||
UNIQUE (preset_story_id, sort)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_preset_scenes_story ON preset_scenes(preset_story_id, sort);
|
||||
@@ -0,0 +1,249 @@
|
||||
-- =====================================================================
|
||||
-- CONTENT SCHEMA — Part 5: CMS (blogs, comments, slides, routes, settings)
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO content, public;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- blogs / landings
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE blog_kind AS ENUM ('Blog','Landing');
|
||||
|
||||
CREATE TABLE blogs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID, -- NULL = global
|
||||
kind blog_kind NOT NULL DEFAULT 'Blog',
|
||||
|
||||
slug CITEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
short_description TEXT,
|
||||
content TEXT NOT NULL,
|
||||
|
||||
-- SEO
|
||||
meta_title TEXT,
|
||||
meta_description TEXT,
|
||||
meta_keywords TEXT,
|
||||
include_in_site_map BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Media
|
||||
image TEXT,
|
||||
cover TEXT,
|
||||
|
||||
-- Author
|
||||
author_user_id UUID, -- references identity.users (loose)
|
||||
author_display_name TEXT,
|
||||
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
publish_date TIMESTAMPTZ,
|
||||
|
||||
view_count BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_blogs_published ON blogs(is_published, publish_date DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_blogs_tenant ON blogs(tenant_id) WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_blogs_updated_at
|
||||
BEFORE UPDATE ON blogs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- comments — on blogs or project containers
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
user_id UUID NOT NULL, -- references identity.users (loose)
|
||||
|
||||
blog_id UUID REFERENCES blogs(id) ON DELETE CASCADE,
|
||||
container_id UUID REFERENCES project_containers(id) ON DELETE CASCADE,
|
||||
parent_comment_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||
|
||||
content TEXT NOT NULL,
|
||||
rate NUMERIC(3,2), -- 0-5
|
||||
|
||||
is_approved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
|
||||
CHECK (
|
||||
(blog_id IS NOT NULL)::INT + (container_id IS NOT NULL)::INT = 1
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_comments_blog ON comments(blog_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_comments_container ON comments(container_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_comments_user ON comments(user_id);
|
||||
CREATE INDEX idx_comments_pending ON comments(is_approved) WHERE is_approved = FALSE AND deleted_at IS NULL;
|
||||
|
||||
CREATE TRIGGER tg_comments_updated_at
|
||||
BEFORE UPDATE ON comments FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- home_page_events — marketing banners / events
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE home_page_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
description TEXT,
|
||||
badge TEXT,
|
||||
badge_class TEXT,
|
||||
button_text TEXT,
|
||||
button_url TEXT,
|
||||
button_class TEXT,
|
||||
color TEXT,
|
||||
background_color TEXT,
|
||||
text_color TEXT,
|
||||
image TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
starts_at TIMESTAMPTZ,
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_home_events_active ON home_page_events(tenant_id, is_active);
|
||||
|
||||
CREATE TRIGGER tg_home_events_updated_at
|
||||
BEFORE UPDATE ON home_page_events FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- new_slides — carousel
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE slide_type AS ENUM ('Hero','Promo','Tutorial','Category','Custom');
|
||||
|
||||
CREATE TABLE new_slides (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
keyword TEXT,
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
parameter TEXT,
|
||||
slide_type slide_type NOT NULL DEFAULT 'Hero',
|
||||
expire_date TIMESTAMPTZ,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_slides_active ON new_slides(tenant_id, is_active);
|
||||
|
||||
CREATE TRIGGER tg_slides_updated_at
|
||||
BEFORE UPDATE ON new_slides FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- internal_routes / custom_routes — SEO + redirects
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE internal_routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
name TEXT,
|
||||
image TEXT,
|
||||
slug CITEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 5,
|
||||
last_date TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE custom_routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
target TEXT NOT NULL, -- source path
|
||||
destination TEXT NOT NULL, -- redirect target
|
||||
redirect_code INT NOT NULL DEFAULT 301,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_custom_routes_active ON custom_routes(tenant_id, is_active);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- website_settings — key-value config (per tenant)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE website_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID, -- NULL = global default
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
description TEXT,
|
||||
is_secret BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_settings_tenant ON website_settings(tenant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- learn — help articles
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE learn (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
demo_url TEXT,
|
||||
mode TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- trainings — video tutorials
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE trainings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
video_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_published BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- favorite_folders — user collections of templates
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE favorite_folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL, -- references identity.users
|
||||
tenant_id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fav_folders_user ON favorite_folders(user_id);
|
||||
|
||||
CREATE TRIGGER tg_fav_folders_updated_at
|
||||
BEFORE UPDATE ON favorite_folders FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- favorite_containers — saved template references in user's folders
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE favorite_containers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
tenant_id UUID NOT NULL,
|
||||
container_id UUID NOT NULL REFERENCES project_containers(id) ON DELETE CASCADE,
|
||||
folder_id UUID REFERENCES favorite_folders(id) ON DELETE SET NULL,
|
||||
note TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, container_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_fav_containers_user ON favorite_containers(user_id);
|
||||
@@ -0,0 +1,335 @@
|
||||
-- =====================================================================
|
||||
-- STUDIO SCHEMA — Saved Projects (User's Project Instances)
|
||||
-- =====================================================================
|
||||
-- This is where user input lives: their text values, color choices,
|
||||
-- media uploads, music, voiceover, etc. The render service reads from
|
||||
-- here to build the JSX.
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO studio, public;
|
||||
|
||||
CREATE TYPE saved_project_type AS ENUM ('Draft','Active','Archived','Trash');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_projects — root of user's project instance
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL, -- references identity.tenants
|
||||
user_id UUID NOT NULL, -- references identity.users
|
||||
|
||||
-- Source template snapshot (so deleting template doesn't break this)
|
||||
original_project_id UUID NOT NULL, -- references content.projects
|
||||
original_project_name TEXT NOT NULL,
|
||||
original_container_id UUID,
|
||||
original_container_slug CITEXT,
|
||||
|
||||
-- Identity
|
||||
name TEXT NOT NULL,
|
||||
image TEXT,
|
||||
type saved_project_type NOT NULL DEFAULT 'Draft',
|
||||
|
||||
-- Snapshot of project metadata
|
||||
frame_rate INT NOT NULL DEFAULT 30,
|
||||
project_duration_sec NUMERIC(8,2) NOT NULL,
|
||||
resolution TEXT NOT NULL, -- HD/FullHD/TwoK/FourK
|
||||
choose_mode TEXT NOT NULL, -- FIX/FLEXIBLE/MockUp/MusicVisualizer
|
||||
vip_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
|
||||
|
||||
-- =====================================
|
||||
-- Audio (NEW — voiceover + volumes)
|
||||
-- =====================================
|
||||
music_file_id UUID, -- references file_mgr.user_files
|
||||
music_track_id UUID, -- references content.music_tracks (library)
|
||||
music_volume NUMERIC(4,3) NOT NULL DEFAULT 0.7 CHECK (music_volume BETWEEN 0 AND 1),
|
||||
|
||||
voiceover_file_id UUID, -- references file_mgr.user_files
|
||||
voiceover_volume NUMERIC(4,3) NOT NULL DEFAULT 1.0 CHECK (voiceover_volume BETWEEN 0 AND 1),
|
||||
voiceover_recorded_in_browser BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
sfx_volume NUMERIC(4,3) NOT NULL DEFAULT 1.0 CHECK (sfx_volume BETWEEN 0 AND 1),
|
||||
sfx_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Music visualizer mode
|
||||
audio_visualizer_music_url TEXT,
|
||||
audio_visualizer_duration_sec NUMERIC(8,2),
|
||||
|
||||
-- =====================================
|
||||
-- Customization options
|
||||
-- =====================================
|
||||
manual_color_picker BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
selected_preset_story_id UUID, -- references content.preset_stories
|
||||
|
||||
-- Auto-save / state
|
||||
last_edit_step TEXT, -- track wizard step
|
||||
edit_state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_edit_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_proj_user ON saved_projects(user_id, last_edit_date DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_saved_proj_tenant ON saved_projects(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_saved_proj_original ON saved_projects(original_project_id);
|
||||
CREATE INDEX idx_saved_proj_name_trgm ON saved_projects USING gin (name gin_trgm_ops);
|
||||
|
||||
CREATE TRIGGER tg_saved_projects_updated_at
|
||||
BEFORE UPDATE ON saved_projects FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scenes — user's chosen scenes (FLEXIBLE mode = multiple per project)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scenes (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
|
||||
|
||||
-- Snapshot from original scene
|
||||
original_scene_id UUID, -- references content.scenes
|
||||
key TEXT NOT NULL, -- AE comp name
|
||||
title TEXT,
|
||||
image TEXT,
|
||||
demo TEXT,
|
||||
scene_color_svg TEXT,
|
||||
scene_type TEXT NOT NULL, -- Normal/Config/DesignStart/DesignEnd
|
||||
|
||||
-- Timing
|
||||
sort INT NOT NULL,
|
||||
scene_length_sec NUMERIC(8,2) NOT NULL,
|
||||
min_duration_sec NUMERIC(8,2),
|
||||
max_duration_sec NUMERIC(8,2),
|
||||
overlap_at_end_sec NUMERIC(6,2) NOT NULL DEFAULT 0,
|
||||
can_handle_duration BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Customization
|
||||
manual_color_selection BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
selected_color_preset_id UUID, -- ref to saved_scene_color_presets
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_scenes_proj ON saved_scenes(saved_project_id, sort);
|
||||
|
||||
CREATE TRIGGER tg_saved_scenes_updated_at
|
||||
BEFORE UPDATE ON saved_scenes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scene_contents — user-filled content values per scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scene_contents (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
|
||||
|
||||
-- Element identity
|
||||
key TEXT NOT NULL,
|
||||
title TEXT,
|
||||
localized_title JSONB,
|
||||
hint TEXT,
|
||||
type TEXT NOT NULL, -- Text/Media/Audio/Voiceover/...
|
||||
|
||||
-- User value
|
||||
value TEXT, -- text or file UUID
|
||||
value_file_id UUID, -- if file: references file_mgr.user_files
|
||||
inserted_file_type TEXT, -- Image/Video/Audio
|
||||
file_url_cached TEXT, -- resolved CDN url at last save
|
||||
file_url_cached_at TIMESTAMPTZ,
|
||||
|
||||
-- Text styling
|
||||
font_face TEXT,
|
||||
font_face_name TEXT,
|
||||
font_size INT,
|
||||
default_font_size INT,
|
||||
default_font_face TEXT,
|
||||
justify TEXT,
|
||||
position_in_container INT NOT NULL DEFAULT 0,
|
||||
direction_layer_value INT NOT NULL DEFAULT 0,
|
||||
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- AI assistance
|
||||
ai_input_type TEXT,
|
||||
|
||||
-- Design pattern choice
|
||||
selected_dp INT, -- 1-4
|
||||
|
||||
-- Repeater
|
||||
repeater_item_key TEXT,
|
||||
repeater_index INT,
|
||||
|
||||
-- Selection state
|
||||
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status TEXT,
|
||||
mapped_list JSONB,
|
||||
thumbnail TEXT,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_contents_scene ON saved_scene_contents(saved_scene_id, sort);
|
||||
CREATE INDEX idx_saved_contents_filerefs ON saved_scene_contents(value_file_id) WHERE value_file_id IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_saved_contents_updated_at
|
||||
BEFORE UPDATE ON saved_scene_contents FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scene_colors — color choices per scene
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scene_colors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
title TEXT,
|
||||
icon TEXT,
|
||||
attr_value TEXT NOT NULL DEFAULT 'fill',
|
||||
value TEXT NOT NULL, -- hex or rgb
|
||||
is_selected BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
UNIQUE (saved_scene_id, element_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_colors_scene ON saved_scene_colors(saved_scene_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scene_color_presets + items
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scene_color_presets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
|
||||
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE saved_scene_color_preset_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
preset_id BIGINT NOT NULL REFERENCES saved_scene_color_presets(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_scp_items_preset ON saved_scene_color_preset_items(preset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scene_characters
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scene_characters (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_scene_id BIGINT NOT NULL REFERENCES saved_scenes(id) ON DELETE CASCADE,
|
||||
key UUID NOT NULL,
|
||||
name TEXT,
|
||||
icon TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_chars_scene ON saved_scene_characters(saved_scene_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_scene_character_controllers
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_scene_character_controllers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_scene_character_id BIGINT NOT NULL REFERENCES saved_scene_characters(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_char_ctrl_char ON saved_scene_character_controllers(saved_scene_character_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_shared_colors — project-level color choices
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_shared_colors (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
title TEXT,
|
||||
icon TEXT,
|
||||
attr_value TEXT NOT NULL DEFAULT 'fill',
|
||||
value TEXT NOT NULL,
|
||||
is_selected BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
UNIQUE (saved_project_id, element_key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_shared_colors_proj ON saved_shared_colors(saved_project_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_shared_color_presets
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_shared_color_presets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
|
||||
name TEXT,
|
||||
is_selected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE saved_shared_color_preset_items (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
preset_id BIGINT NOT NULL REFERENCES saved_shared_color_presets(id) ON DELETE CASCADE,
|
||||
element_key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
sort INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_sscp_items_preset ON saved_shared_color_preset_items(preset_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- saved_shared_layers — project-level layer values
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE saved_shared_layers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
saved_project_id UUID NOT NULL REFERENCES saved_projects(id) ON DELETE CASCADE,
|
||||
|
||||
key TEXT NOT NULL,
|
||||
title TEXT,
|
||||
localized_title JSONB,
|
||||
hint TEXT,
|
||||
type TEXT NOT NULL,
|
||||
|
||||
value TEXT,
|
||||
value_file_id UUID,
|
||||
file_url_cached TEXT,
|
||||
file_url_cached_at TIMESTAMPTZ,
|
||||
|
||||
font_face TEXT,
|
||||
font_face_name TEXT,
|
||||
font_size INT,
|
||||
default_font_size INT,
|
||||
default_font_face TEXT,
|
||||
justify TEXT,
|
||||
position_in_container INT NOT NULL DEFAULT 0,
|
||||
direction_layer_value INT NOT NULL DEFAULT 0,
|
||||
is_text_box BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
ai_input_type TEXT,
|
||||
mapped_list JSONB,
|
||||
thumbnail TEXT,
|
||||
|
||||
width INT,
|
||||
height INT,
|
||||
min_duration_sec NUMERIC(6,2),
|
||||
max_duration_sec NUMERIC(6,2),
|
||||
|
||||
is_focused BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_font_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_font_size_changeable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
status TEXT,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (saved_project_id, key)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_saved_shared_layers_proj ON saved_shared_layers(saved_project_id);
|
||||
|
||||
CREATE TRIGGER tg_saved_shared_layers_updated_at
|
||||
BEFORE UPDATE ON saved_shared_layers FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
@@ -0,0 +1,176 @@
|
||||
-- =====================================================================
|
||||
-- RENDER SCHEMA — Part 1: Farm Nodes & Health
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO render, public;
|
||||
|
||||
CREATE TYPE node_status AS ENUM ('Ready','Busy','Offline','Maintenance','Crashed','Updating','Disabled');
|
||||
CREATE TYPE node_kind AS ENUM ('Shared','Dedicated','Spot');
|
||||
CREATE TYPE render_type AS ENUM ('Free','Paid','Snapshot','Mockup');
|
||||
CREATE TYPE ae_version AS ENUM ('2020','2021','2022','2023','2024','2025');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- render_nodes — registry of farm machines
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE render_nodes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
region TEXT NOT NULL, -- 'tehran','frankfurt',...
|
||||
|
||||
-- Network
|
||||
node_ip INET NOT NULL,
|
||||
worker_port INT NOT NULL DEFAULT 5555,
|
||||
public_endpoint TEXT,
|
||||
|
||||
-- Spec
|
||||
ram_gb INT,
|
||||
cpu_cores INT,
|
||||
gpu_model TEXT,
|
||||
storage_gb INT,
|
||||
|
||||
-- Software
|
||||
current_ae_version ae_version NOT NULL,
|
||||
available_ae_versions TEXT[] NOT NULL DEFAULT '{}',
|
||||
node_agent_version TEXT,
|
||||
last_update_at TIMESTAMPTZ,
|
||||
last_update_error TEXT,
|
||||
|
||||
-- Ownership
|
||||
node_kind node_kind NOT NULL DEFAULT 'Shared',
|
||||
owner_user_id UUID, -- references identity.users (for Dedicated)
|
||||
owner_tenant_id UUID, -- references identity.tenants
|
||||
|
||||
-- State
|
||||
status node_status NOT NULL DEFAULT 'Offline',
|
||||
current_job_id UUID, -- references render_jobs
|
||||
current_frame_job_id UUID, -- references frame_jobs
|
||||
job_started_at TIMESTAMPTZ,
|
||||
render_type render_type, -- which queue it's serving now
|
||||
|
||||
-- Health (denormalized for hot reads)
|
||||
last_heartbeat_at TIMESTAMPTZ,
|
||||
last_cpu_pct INT,
|
||||
last_ram_available_mb INT,
|
||||
ae_running BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Stats
|
||||
lifetime_task_count BIGINT NOT NULL DEFAULT 0,
|
||||
lifetime_crash_count INT NOT NULL DEFAULT 0,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Scheduling
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
accepts_new_jobs BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Maintenance
|
||||
last_maintenance_at TIMESTAMPTZ,
|
||||
next_maintenance_at TIMESTAMPTZ,
|
||||
maintenance_reason TEXT,
|
||||
|
||||
-- Local cache state (templates the node has downloaded)
|
||||
cached_template_md5s TEXT[] NOT NULL DEFAULT '{}',
|
||||
cache_used_gb INT NOT NULL DEFAULT 0,
|
||||
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_nodes_region_status ON render_nodes(region, status) WHERE is_active = TRUE;
|
||||
CREATE INDEX idx_nodes_ready ON render_nodes(region, priority DESC)
|
||||
WHERE status = 'Ready' AND accepts_new_jobs = TRUE AND is_active = TRUE;
|
||||
CREATE INDEX idx_nodes_owner ON render_nodes(owner_user_id) WHERE node_kind = 'Dedicated';
|
||||
CREATE INDEX idx_nodes_heartbeat ON render_nodes(last_heartbeat_at) WHERE is_active = TRUE;
|
||||
CREATE UNIQUE INDEX uq_nodes_ip_port ON render_nodes(node_ip, worker_port);
|
||||
|
||||
CREATE TRIGGER tg_render_nodes_updated_at
|
||||
BEFORE UPDATE ON render_nodes FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- node_health_logs — historical heartbeat data (partitioned monthly)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE node_health_logs (
|
||||
id BIGSERIAL,
|
||||
node_id UUID NOT NULL,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
status node_status NOT NULL,
|
||||
cpu_pct INT,
|
||||
ram_available_mb INT,
|
||||
ae_running BOOLEAN,
|
||||
current_job_id UUID,
|
||||
current_frame INT,
|
||||
|
||||
-- Templates cached (size summary only)
|
||||
cache_used_gb INT,
|
||||
|
||||
PRIMARY KEY (id, recorded_at)
|
||||
) PARTITION BY RANGE (recorded_at);
|
||||
|
||||
CREATE INDEX idx_node_health_node ON node_health_logs(node_id, recorded_at DESC);
|
||||
|
||||
CREATE TABLE node_health_logs_y2026m01
|
||||
PARTITION OF node_health_logs
|
||||
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- node_crashes — every detected AE crash
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE node_crashes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
node_id UUID NOT NULL REFERENCES render_nodes(id) ON DELETE CASCADE,
|
||||
render_job_id UUID, -- which job was running
|
||||
frame_job_id UUID,
|
||||
|
||||
crashed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_known_frame INT,
|
||||
crash_signal TEXT, -- exit code or signal
|
||||
error_log TEXT, -- last N lines of AE log
|
||||
log_file_url TEXT, -- MinIO upload of full log
|
||||
|
||||
-- Recovery
|
||||
auto_recovered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
recovery_action TEXT, -- 'reset_prefs','restart_ae','reassign_job'
|
||||
recovered_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_node_crashes_node ON node_crashes(node_id, crashed_at DESC);
|
||||
CREATE INDEX idx_node_crashes_job ON node_crashes(render_job_id) WHERE render_job_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- node_updates — software/AE update tracking
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE node_updates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
update_file_name TEXT NOT NULL,
|
||||
update_number INT NOT NULL,
|
||||
description TEXT,
|
||||
target_ae_version ae_version,
|
||||
in_update_queue BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
rolled_out_to_node_ids UUID[] NOT NULL DEFAULT '{}',
|
||||
last_update_queue_date TIMESTAMPTZ,
|
||||
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- node_template_cache — what's currently cached on each node
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE node_template_cache (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
node_id UUID NOT NULL REFERENCES render_nodes(id) ON DELETE CASCADE,
|
||||
project_id UUID NOT NULL, -- references content.projects
|
||||
aep_file_md5 TEXT NOT NULL,
|
||||
file_size_bytes BIGINT NOT NULL,
|
||||
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
use_count INT NOT NULL DEFAULT 1,
|
||||
local_path TEXT NOT NULL,
|
||||
UNIQUE (node_id, aep_file_md5)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_node_cache_node_lru ON node_template_cache(node_id, last_used_at);
|
||||
CREATE INDEX idx_node_cache_md5 ON node_template_cache(aep_file_md5);
|
||||
@@ -0,0 +1,315 @@
|
||||
-- =====================================================================
|
||||
-- RENDER SCHEMA — Part 2: Jobs, Frames, Snapshots, Exports
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO render, public;
|
||||
|
||||
CREATE TYPE render_step AS ENUM (
|
||||
'Queued','Preparing','TemplateCache','JsxGen','Music',
|
||||
'Rendering','Validating','Repairing','Optimisation','Video',
|
||||
'Mixing','Final','Uploading','Done','Failed','Cancelled'
|
||||
);
|
||||
|
||||
CREATE TYPE price_kind AS ENUM ('Free','Preview','Cash','Plan','Snapshot','Reseller');
|
||||
|
||||
CREATE TYPE render_quality AS ENUM ('Low','Medium','High','Full','Lossless');
|
||||
|
||||
CREATE TYPE frame_job_status AS ENUM (
|
||||
'Pending','Rendering','Validated','Repairing','Converting','Done','Failed'
|
||||
);
|
||||
|
||||
CREATE TYPE render_priority_queue AS ENUM (
|
||||
'snapshot','vip','paid','preview','mockup','voiceover'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- render_jobs — top-level render task
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE render_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
-- References (loose cross-schema)
|
||||
saved_project_id UUID NOT NULL,
|
||||
original_project_id UUID NOT NULL,
|
||||
project_name TEXT,
|
||||
|
||||
-- Job identity
|
||||
title TEXT,
|
||||
name TEXT,
|
||||
external_job_id TEXT, -- RabbitMQ message ID
|
||||
priority_queue render_priority_queue NOT NULL,
|
||||
priority_score INT NOT NULL DEFAULT 50, -- 0-100, higher = sooner
|
||||
|
||||
-- Pipeline state
|
||||
step render_step NOT NULL DEFAULT 'Queued',
|
||||
render_progress INT NOT NULL DEFAULT 0 CHECK (render_progress BETWEEN 0 AND 100),
|
||||
convert_progress INT NOT NULL DEFAULT 0,
|
||||
image_preview_b64 TEXT, -- last frame thumbnail
|
||||
|
||||
-- Pricing
|
||||
price_type price_kind NOT NULL,
|
||||
paid_price_minor BIGINT NOT NULL DEFAULT 0,
|
||||
discount_code TEXT,
|
||||
support_flatrender BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Output config
|
||||
mode TEXT NOT NULL, -- FIX/FLEXIBLE/MockUp/MusicVisualizer
|
||||
quality render_quality NOT NULL DEFAULT 'High',
|
||||
resolution TEXT NOT NULL, -- FullHD/FourK
|
||||
r_height INT NOT NULL,
|
||||
frame_rate INT NOT NULL DEFAULT 30,
|
||||
is_60_fps BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_sec NUMERIC(8,2) NOT NULL,
|
||||
export_duration_sec NUMERIC(8,2),
|
||||
|
||||
-- Audio (NEW)
|
||||
has_music BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_sfx BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
has_voiceover BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
music_volume NUMERIC(4,3),
|
||||
sfx_volume NUMERIC(4,3),
|
||||
voiceover_volume NUMERIC(4,3),
|
||||
|
||||
-- Resource allocation
|
||||
render_node_count INT NOT NULL DEFAULT 1,
|
||||
current_active_nodes INT NOT NULL DEFAULT 0,
|
||||
region TEXT, -- preferred region
|
||||
tell_me_when_done BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
-- Retry / recovery
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
max_retries INT NOT NULL DEFAULT 3,
|
||||
repair_attempts INT NOT NULL DEFAULT 0,
|
||||
failed_message TEXT,
|
||||
failed_at_step render_step,
|
||||
|
||||
-- File outputs (paths in MinIO; final URL goes in exports.path)
|
||||
render_folder TEXT, -- temp working dir
|
||||
output_folder TEXT, -- frames dir
|
||||
physical_render_folder TEXT, -- absolute path for nodes
|
||||
physical_output_folder TEXT,
|
||||
target_replica_name TEXT,
|
||||
|
||||
-- Reference to result
|
||||
export_id UUID,
|
||||
|
||||
-- Timing
|
||||
task_start_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
start_render_date TIMESTAMPTZ,
|
||||
done_task_date TIMESTAMPTZ,
|
||||
queued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_render_jobs_user ON render_jobs(user_id, created_at DESC);
|
||||
CREATE INDEX idx_render_jobs_tenant ON render_jobs(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_render_jobs_step ON render_jobs(step) WHERE step NOT IN ('Done','Failed','Cancelled');
|
||||
CREATE INDEX idx_render_jobs_queue ON render_jobs(priority_queue, priority_score DESC, queued_at)
|
||||
WHERE step = 'Queued';
|
||||
CREATE INDEX idx_render_jobs_in_flight ON render_jobs(started_at) WHERE step NOT IN ('Done','Failed','Cancelled','Queued');
|
||||
|
||||
CREATE TRIGGER tg_render_jobs_updated_at
|
||||
BEFORE UPDATE ON render_jobs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- frame_jobs — per-node frame-range assignments
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE frame_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
|
||||
node_id UUID NOT NULL REFERENCES render_nodes(id),
|
||||
|
||||
-- Range
|
||||
start_frame INT NOT NULL,
|
||||
end_frame INT NOT NULL,
|
||||
collect_frame_count INT NOT NULL,
|
||||
order_value INT NOT NULL DEFAULT 0,
|
||||
folder_name TEXT NOT NULL, -- "O0", "O1"...
|
||||
convert_url TEXT,
|
||||
|
||||
-- Status
|
||||
status frame_job_status NOT NULL DEFAULT 'Pending',
|
||||
frames_rendered INT NOT NULL DEFAULT 0,
|
||||
frames_validated INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Errors
|
||||
attempt INT NOT NULL DEFAULT 1,
|
||||
last_error TEXT,
|
||||
|
||||
-- Outputs
|
||||
output_mp4_url TEXT, -- chunk MP4 after ffmpeg
|
||||
|
||||
-- Timing
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
started_at TIMESTAMPTZ,
|
||||
last_progress_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_frame_jobs_render ON frame_jobs(render_job_id, order_value);
|
||||
CREATE INDEX idx_frame_jobs_node ON frame_jobs(node_id, status);
|
||||
CREATE INDEX idx_frame_jobs_stalled ON frame_jobs(last_progress_at) WHERE status = 'Rendering';
|
||||
|
||||
CREATE TRIGGER tg_frame_jobs_updated_at
|
||||
BEFORE UPDATE ON frame_jobs FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- frame_repair_jobs — missing/corrupt frame repair tracking
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE frame_repair_jobs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
|
||||
node_id UUID REFERENCES render_nodes(id),
|
||||
|
||||
-- Range to repair
|
||||
start_frame INT NOT NULL,
|
||||
end_frame INT NOT NULL,
|
||||
missing_frames INT[] NOT NULL DEFAULT '{}',
|
||||
corrupt_frames INT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
attempt INT NOT NULL DEFAULT 1,
|
||||
status frame_job_status NOT NULL DEFAULT 'Pending',
|
||||
error TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_frame_repair_job ON frame_repair_jobs(render_job_id, attempt);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- snapshots — single-frame scene previews (new feature)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE snapshots (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
saved_project_id UUID NOT NULL,
|
||||
scene_key TEXT NOT NULL,
|
||||
frame_number INT NOT NULL,
|
||||
|
||||
-- Cache key — same inputs = same output
|
||||
inputs_hash TEXT NOT NULL,
|
||||
|
||||
-- Status
|
||||
status TEXT NOT NULL DEFAULT 'Pending', -- Pending/Rendering/Done/Failed
|
||||
render_node_id UUID REFERENCES render_nodes(id),
|
||||
|
||||
-- Output
|
||||
image_url TEXT,
|
||||
thumbnail_url TEXT,
|
||||
width INT,
|
||||
height INT,
|
||||
size_bytes BIGINT,
|
||||
|
||||
-- Timing
|
||||
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
duration_ms INT,
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX uq_snapshots_cache ON snapshots(saved_project_id, scene_key, frame_number, inputs_hash)
|
||||
WHERE status = 'Done';
|
||||
CREATE INDEX idx_snapshots_user ON snapshots(user_id, requested_at DESC);
|
||||
CREATE INDEX idx_snapshots_expire ON snapshots(expires_at) WHERE status = 'Done';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- exports — final rendered output records
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TYPE export_create_type AS ENUM ('Render','Upload','Snapshot','Reupload');
|
||||
CREATE TYPE export_file_type AS ENUM ('Video','Image','Audio','GIF','PDF');
|
||||
|
||||
CREATE TABLE exports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
saved_project_id UUID NOT NULL,
|
||||
project_id UUID NOT NULL,
|
||||
render_job_id UUID REFERENCES render_jobs(id) ON DELETE SET NULL,
|
||||
|
||||
-- Output
|
||||
image TEXT, -- thumbnail
|
||||
path TEXT NOT NULL, -- main file URL
|
||||
file_extension TEXT NOT NULL DEFAULT 'mp4',
|
||||
file_type export_file_type NOT NULL DEFAULT 'Video',
|
||||
|
||||
render_quality render_quality NOT NULL,
|
||||
create_type export_create_type NOT NULL DEFAULT 'Render',
|
||||
size_bytes BIGINT NOT NULL,
|
||||
duration_sec NUMERIC(8,2),
|
||||
width INT,
|
||||
height INT,
|
||||
|
||||
-- Lifecycle
|
||||
produce_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
auto_delete_date TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '30 days',
|
||||
delete_notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_exports_user ON exports(user_id, produce_date DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_exports_tenant ON exports(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_exports_saved_project ON exports(saved_project_id);
|
||||
CREATE INDEX idx_exports_auto_delete ON exports(auto_delete_date) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Now wire FK back to render_jobs
|
||||
ALTER TABLE render_jobs
|
||||
ADD CONSTRAINT fk_render_jobs_export
|
||||
FOREIGN KEY (export_id) REFERENCES exports(id) ON DELETE SET NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- export_files — mockup mode produces multiple images per export
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE export_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
export_id UUID NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
name TEXT,
|
||||
thumbnail TEXT,
|
||||
path TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
file_type export_file_type NOT NULL DEFAULT 'Image',
|
||||
width INT,
|
||||
height INT,
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_export_files_export ON export_files(export_id, sort);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- render_progress_events — WebSocket fan-out source (short-lived)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE render_progress_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
render_job_id UUID NOT NULL REFERENCES render_jobs(id) ON DELETE CASCADE,
|
||||
step render_step NOT NULL,
|
||||
progress INT NOT NULL,
|
||||
current_frame INT,
|
||||
total_frames INT,
|
||||
eta_seconds INT,
|
||||
preview_b64 TEXT,
|
||||
message TEXT,
|
||||
emitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_progress_events_job ON render_progress_events(render_job_id, emitted_at DESC);
|
||||
-- Cleanup: keep last N per job via cron, drop > 7 days
|
||||
@@ -0,0 +1,236 @@
|
||||
-- =====================================================================
|
||||
-- FILE_MGR SCHEMA — File Manager, Storage Quotas, Cleanup Scheduler
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO file_mgr, public;
|
||||
|
||||
CREATE TYPE file_kind AS ENUM ('Video','Image','Audio','Voiceover','Document','Other');
|
||||
CREATE TYPE folder_kind AS ENUM ('System','User','Shared','Tenant');
|
||||
CREATE TYPE upload_status AS ENUM ('Pending','Uploading','Processing','Ready','Failed','Quarantined');
|
||||
CREATE TYPE cleanup_entity_type AS ENUM ('Export','TempRenderFolder','OrphanedFile','UnusedUpload','SnapshotExpired');
|
||||
CREATE TYPE cleanup_status AS ENUM ('Scheduled','Notified','Processing','Done','Skipped','Failed');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_folders — hierarchical folders
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE user_folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
name TEXT NOT NULL,
|
||||
folder_type folder_kind NOT NULL DEFAULT 'User',
|
||||
parent_folder_id UUID REFERENCES user_folders(id) ON DELETE CASCADE,
|
||||
|
||||
-- Stats (denormalized for fast UI)
|
||||
file_count INT NOT NULL DEFAULT 0,
|
||||
total_size_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
is_shared BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
share_token TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_folders_user ON user_folders(user_id, parent_folder_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_folders_parent ON user_folders(parent_folder_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_folders_share ON user_folders(share_token) WHERE share_token IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_folders_updated_at
|
||||
BEFORE UPDATE ON user_folders FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- user_files
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE user_files (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
user_folder_id UUID REFERENCES user_folders(id) ON DELETE SET NULL,
|
||||
|
||||
-- Identity
|
||||
name TEXT NOT NULL,
|
||||
original_filename TEXT,
|
||||
file_extension TEXT,
|
||||
mime_type TEXT,
|
||||
file_type file_kind NOT NULL,
|
||||
|
||||
-- Storage
|
||||
minio_bucket TEXT NOT NULL,
|
||||
minio_key TEXT NOT NULL,
|
||||
cdn_url TEXT,
|
||||
file_address TEXT NOT NULL, -- canonical URL
|
||||
size_bytes BIGINT NOT NULL,
|
||||
md5_hash TEXT,
|
||||
sha256_hash TEXT,
|
||||
|
||||
-- Media metadata
|
||||
duration_sec NUMERIC(8,2),
|
||||
width INT,
|
||||
height INT,
|
||||
fps NUMERIC(5,2),
|
||||
bitrate_kbps INT,
|
||||
codec TEXT,
|
||||
has_audio BOOLEAN,
|
||||
has_video BOOLEAN,
|
||||
|
||||
-- Thumbnails
|
||||
thumbnail_url TEXT,
|
||||
waveform_data JSONB, -- for audio files
|
||||
|
||||
-- Upload state
|
||||
upload_status upload_status NOT NULL DEFAULT 'Ready',
|
||||
upload_id TEXT, -- multipart upload ID if used
|
||||
upload_progress INT NOT NULL DEFAULT 100,
|
||||
processing_error TEXT,
|
||||
|
||||
-- Source / linkage
|
||||
source TEXT, -- 'upload','export','snapshot','voiceover_record','stock'
|
||||
export_id UUID, -- references render.exports
|
||||
parent_file_id UUID REFERENCES user_files(id) ON DELETE SET NULL, -- derived files
|
||||
|
||||
-- Lifecycle
|
||||
last_used_at TIMESTAMPTZ,
|
||||
use_count INT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Sharing
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
share_token TEXT,
|
||||
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_files_user_folder ON user_files(user_id, user_folder_id, created_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_tenant ON user_files(tenant_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_type ON user_files(user_id, file_type) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_hash ON user_files(md5_hash) WHERE md5_hash IS NOT NULL;
|
||||
CREATE INDEX idx_files_unused ON user_files(last_used_at) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_files_name_trgm ON user_files USING gin (name gin_trgm_ops);
|
||||
CREATE INDEX idx_files_share ON user_files(share_token) WHERE share_token IS NOT NULL;
|
||||
|
||||
CREATE TRIGGER tg_files_updated_at
|
||||
BEFORE UPDATE ON user_files FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- storage_quotas — current usage per user
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE storage_quotas (
|
||||
user_id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
|
||||
plan_quota_bytes BIGINT NOT NULL DEFAULT 0, -- from plan
|
||||
bonus_quota_bytes BIGINT NOT NULL DEFAULT 0, -- purchased extra
|
||||
used_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Cached counts
|
||||
video_count INT NOT NULL DEFAULT 0,
|
||||
image_count INT NOT NULL DEFAULT 0,
|
||||
audio_count INT NOT NULL DEFAULT 0,
|
||||
video_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
image_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
audio_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
|
||||
-- Notifications
|
||||
last_90pct_notified_at TIMESTAMPTZ,
|
||||
last_100pct_notified_at TIMESTAMPTZ,
|
||||
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_quotas_tenant ON storage_quotas(tenant_id);
|
||||
|
||||
CREATE TRIGGER tg_quotas_updated_at
|
||||
BEFORE UPDATE ON storage_quotas FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- cleanup_schedules — track what's queued for auto-delete
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE cleanup_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID,
|
||||
user_id UUID,
|
||||
|
||||
entity_type cleanup_entity_type NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
entity_path TEXT, -- filesystem path for temp folders
|
||||
|
||||
scheduled_delete_at TIMESTAMPTZ NOT NULL,
|
||||
notify_user_at TIMESTAMPTZ, -- send "expires in 3 days" notice
|
||||
user_notified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
user_notified_at TIMESTAMPTZ,
|
||||
|
||||
status cleanup_status NOT NULL DEFAULT 'Scheduled',
|
||||
processed_at TIMESTAMPTZ,
|
||||
processing_error TEXT,
|
||||
bytes_freed BIGINT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_cleanup_due ON cleanup_schedules(scheduled_delete_at) WHERE status = 'Scheduled';
|
||||
CREATE INDEX idx_cleanup_notify_due ON cleanup_schedules(notify_user_at) WHERE user_notified = FALSE AND notify_user_at IS NOT NULL;
|
||||
CREATE INDEX idx_cleanup_entity ON cleanup_schedules(entity_type, entity_id);
|
||||
|
||||
CREATE TRIGGER tg_cleanup_updated_at
|
||||
BEFORE UPDATE ON cleanup_schedules FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- upload_sessions — multipart / chunked upload tracking
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE upload_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
minio_bucket TEXT NOT NULL,
|
||||
minio_key TEXT NOT NULL,
|
||||
minio_upload_id TEXT NOT NULL, -- S3/MinIO multipart ID
|
||||
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT,
|
||||
total_size_bytes BIGINT NOT NULL,
|
||||
chunks_received INT NOT NULL DEFAULT 0,
|
||||
bytes_received BIGINT NOT NULL DEFAULT 0,
|
||||
chunk_size_bytes INT NOT NULL DEFAULT 5242880, -- 5MB default
|
||||
|
||||
target_folder_id UUID REFERENCES user_folders(id) ON DELETE SET NULL,
|
||||
target_file_id UUID, -- created when complete
|
||||
|
||||
status upload_status NOT NULL DEFAULT 'Uploading',
|
||||
error_message TEXT,
|
||||
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours',
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_uploads_user ON upload_sessions(user_id, created_at DESC);
|
||||
CREATE INDEX idx_uploads_expired ON upload_sessions(expires_at) WHERE status = 'Uploading';
|
||||
|
||||
CREATE TRIGGER tg_uploads_updated_at
|
||||
BEFORE UPDATE ON upload_sessions FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- minio_buckets — bucket registry (per region, per purpose)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE minio_buckets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
region TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL, -- 'templates','user-uploads','exports','snapshots','voiceovers'
|
||||
is_public BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
cdn_base_url TEXT,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_buckets_region_purpose ON minio_buckets(region, purpose) WHERE is_active = TRUE;
|
||||
@@ -0,0 +1,194 @@
|
||||
-- =====================================================================
|
||||
-- NOTIFICATION SCHEMA — In-app, Push, Email, SMS, Telegram
|
||||
-- =====================================================================
|
||||
|
||||
SET search_path TO notification, public;
|
||||
|
||||
CREATE TYPE notification_kind AS ENUM (
|
||||
'RenderCompleted','RenderFailed','RenderProgress',
|
||||
'PlanExpiring','PlanExpired','PaymentSuccess','PaymentFailed',
|
||||
'StorageWarning','StorageFull','ExportExpiring','ExportDeleted',
|
||||
'GiftEarned','QuestCompleted','LevelUp',
|
||||
'AccountSecurity','SystemAnnouncement','TenantInvite',
|
||||
'Marketing','Other'
|
||||
);
|
||||
|
||||
CREATE TYPE notification_priority AS ENUM ('Low','Normal','High','Urgent');
|
||||
|
||||
CREATE TYPE delivery_channel AS ENUM ('InApp','Push','Email','SMS','Telegram','Webhook');
|
||||
|
||||
CREATE TYPE delivery_status_kind AS ENUM (
|
||||
'Pending','Sent','Delivered','Failed','Bounced','Suppressed'
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- notifications — in-app notification feed
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
notification_type notification_kind NOT NULL,
|
||||
priority notification_priority NOT NULL DEFAULT 'Normal',
|
||||
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
label TEXT,
|
||||
signature TEXT,
|
||||
icon TEXT,
|
||||
image TEXT,
|
||||
animation_demo TEXT,
|
||||
design TEXT,
|
||||
|
||||
-- Link target
|
||||
action_url TEXT,
|
||||
action_text TEXT,
|
||||
|
||||
-- Linked entities (sparse)
|
||||
render_job_id UUID,
|
||||
export_id UUID,
|
||||
payment_id UUID,
|
||||
gift_id UUID,
|
||||
earned_gift_id UUID,
|
||||
|
||||
-- State
|
||||
is_emergency BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
seen BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
seen_at TIMESTAMPTZ,
|
||||
clicked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
clicked_at TIMESTAMPTZ,
|
||||
gift_used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Lifecycle
|
||||
expire_date TIMESTAMPTZ,
|
||||
create_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notifs_user_feed ON notifications(user_id, created_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_notifs_unread ON notifications(user_id) WHERE seen = FALSE AND deleted_at IS NULL;
|
||||
CREATE INDEX idx_notifs_tenant_type ON notifications(tenant_id, notification_type);
|
||||
|
||||
CREATE TRIGGER tg_notifs_updated_at
|
||||
BEFORE UPDATE ON notifications FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- readed_notifications — read receipts (kept for analytics)
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE readed_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
read_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, notification_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_readed_user ON readed_notifications(user_id, read_date DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- notification_deliveries — outbound across channels
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE notification_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
user_id UUID NOT NULL,
|
||||
notification_id UUID REFERENCES notifications(id) ON DELETE SET NULL,
|
||||
|
||||
channel delivery_channel NOT NULL,
|
||||
recipient TEXT NOT NULL, -- email/phone/push endpoint
|
||||
subject TEXT,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
template_id TEXT, -- reference to template engine
|
||||
template_vars JSONB,
|
||||
|
||||
-- Provider tracking
|
||||
provider TEXT, -- 'web-push','smtp','kavenegar','telegram','firebase'
|
||||
provider_message_id TEXT,
|
||||
provider_response JSONB,
|
||||
|
||||
status delivery_status_kind NOT NULL DEFAULT 'Pending',
|
||||
error_message TEXT,
|
||||
error_code TEXT,
|
||||
|
||||
-- Retry
|
||||
attempt INT NOT NULL DEFAULT 1,
|
||||
max_attempts INT NOT NULL DEFAULT 3,
|
||||
next_retry_at TIMESTAMPTZ,
|
||||
|
||||
-- Timing
|
||||
sent_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
failed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deliveries_user ON notification_deliveries(user_id, created_at DESC);
|
||||
CREATE INDEX idx_deliveries_pending ON notification_deliveries(next_retry_at)
|
||||
WHERE status IN ('Pending','Failed') AND next_retry_at IS NOT NULL;
|
||||
CREATE INDEX idx_deliveries_channel ON notification_deliveries(channel, status);
|
||||
|
||||
CREATE TRIGGER tg_deliveries_updated_at
|
||||
BEFORE UPDATE ON notification_deliveries FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- notification_preferences — per-user opt-in per channel per type
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE notification_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
notification_type notification_kind NOT NULL,
|
||||
channel delivery_channel NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id, notification_type, channel)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notif_prefs_user ON notification_preferences(user_id);
|
||||
|
||||
CREATE TRIGGER tg_notif_prefs_updated_at
|
||||
BEFORE UPDATE ON notification_preferences FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- notification_templates — reusable templates per tenant
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE notification_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID, -- NULL = default
|
||||
|
||||
code TEXT NOT NULL, -- 'render.completed.email'
|
||||
channel delivery_channel NOT NULL,
|
||||
locale TEXT NOT NULL DEFAULT 'fa',
|
||||
|
||||
subject TEXT,
|
||||
body_text TEXT,
|
||||
body_html TEXT,
|
||||
push_title TEXT,
|
||||
push_body TEXT,
|
||||
push_icon TEXT,
|
||||
|
||||
variables_schema JSONB, -- expected variables
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (tenant_id, code, channel, locale)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notif_tpl_lookup ON notification_templates(tenant_id, code, channel, locale) WHERE is_active = TRUE;
|
||||
|
||||
CREATE TRIGGER tg_notif_tpl_updated_at
|
||||
BEFORE UPDATE ON notification_templates FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Wire back: earned_gifts.notification_id
|
||||
-- ---------------------------------------------------------------------
|
||||
ALTER TABLE identity.earned_gifts
|
||||
ADD CONSTRAINT fk_earned_gifts_notification
|
||||
FOREIGN KEY (notification_id) REFERENCES notification.notifications(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- =====================================================================
|
||||
-- IDENTITY SCHEMA — Part 15: Add Tara and SnapPay payment gateways
|
||||
-- =====================================================================
|
||||
-- ALTER TYPE ... ADD VALUE cannot run inside a transaction block in older
|
||||
-- Postgres; use IF NOT EXISTS to make it idempotent.
|
||||
|
||||
ALTER TYPE identity.payment_gateway ADD VALUE IF NOT EXISTS 'Tara';
|
||||
ALTER TYPE identity.payment_gateway ADD VALUE IF NOT EXISTS 'SnapPay';
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 16_fix_inet_to_text.sql
|
||||
-- The C# domain models all IP/network columns as string / string[]. The original
|
||||
-- schema declared them as native PostgreSQL INET / INET[], which fails at runtime
|
||||
-- with: "column ... is of type inet but expression is of type text".
|
||||
--
|
||||
-- Rather than add per-property EF value converters across every service, we align
|
||||
-- the schema with the (string-based) code: convert every inet/inet[] column to
|
||||
-- text/text[]. This block is idempotent — it only touches columns still typed inet,
|
||||
-- and alters partitioned parents (which cascade) while skipping partition children.
|
||||
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT n.nspname AS sch, c.relname AS tbl, a.attname AS col, t.typname AS typ
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON c.oid = a.attrelid
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
JOIN pg_type t ON t.oid = a.atttypid
|
||||
WHERE n.nspname IN ('identity','content','studio','render','notification','file_mgr')
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
AND c.relkind IN ('r','p') -- ordinary + partitioned parents
|
||||
AND NOT c.relispartition -- skip partition children (parent cascades)
|
||||
AND t.typname IN ('inet','_inet')
|
||||
LOOP
|
||||
IF r.typ = '_inet' THEN
|
||||
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE text[] USING %I::text[];',
|
||||
r.sch, r.tbl, r.col, r.col);
|
||||
ELSE
|
||||
EXECUTE format('ALTER TABLE %I.%I ALTER COLUMN %I TYPE text USING %I::text;',
|
||||
r.sch, r.tbl, r.col, r.col);
|
||||
END IF;
|
||||
RAISE NOTICE 'inet->text: %.%.% (%)', r.sch, r.tbl, r.col, r.typ;
|
||||
END LOOP;
|
||||
END $$;
|
||||
Reference in New Issue
Block a user