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:
soroush.asadi
2026-05-29 23:29:31 +03:30
parent 53ea78a00d
commit 90ac0b81d1
7636 changed files with 3707504 additions and 240 deletions
+61
View File
@@ -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)
+305
View File
@@ -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
+129
View File
@@ -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.
+61
View File
@@ -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 }
+114
View File
@@ -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 }
+177
View File
@@ -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 }
+122
View File
@@ -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 }
+219
View File
@@ -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 }
+32
View File
@@ -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 }
+72
View File
@@ -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
+823
View File
@@ -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 }
+661
View File
@@ -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 |
+90
View File
@@ -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.
+51
View File
@@ -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);
+227
View File
@@ -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();
+368
View File
@@ -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);
+249
View File
@@ -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();
+176
View File
@@ -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);
+315
View File
@@ -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
+236
View File
@@ -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;
+194
View File
@@ -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 $$;