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
+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 }