feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file, render, notification, gateway (Go) services with vendored deps, plus DB migrations, event/API contracts, and an init-db script. Wire the Next.js frontend to the gateway: server-side JWT auth routes (login/register/refresh/logout/me), gateway fetch helper, and session/ cookie/jwt helpers under src/lib. Containerize the stack via docker-compose.v2.yml and per-service Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via next/font/local to avoid Google Fonts (geo-blocked). Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
# RabbitMQ Event Catalog
|
||||
|
||||
All async communication between services uses RabbitMQ. Events follow
|
||||
strict naming: `{domain}.{entity}.{verb}.v{n}` (past tense).
|
||||
|
||||
## Exchanges
|
||||
|
||||
| Exchange | Type | Purpose |
|
||||
|---------------------------|----------|--------------------------------------|
|
||||
| `flatrender.events` | topic | Domain events (fan-out by routing) |
|
||||
| `flatrender.render` | direct | Render job dispatch (per queue) |
|
||||
| `flatrender.notify` | direct | Notification dispatch (per channel) |
|
||||
| `flatrender.dlq` | fanout | Dead-letter queue |
|
||||
|
||||
## Common envelope
|
||||
|
||||
Every message body has this shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "uuid",
|
||||
"event_type": "render.job.completed.v1",
|
||||
"event_time": "2026-05-27T10:15:00Z",
|
||||
"tenant_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"trace_id": "uuid",
|
||||
"correlation_id": "uuid",
|
||||
"producer": "render-orchestrator",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Headers (AMQP)
|
||||
|
||||
| Header | Required | Notes |
|
||||
|---------------------|----------|------------------------------------------------|
|
||||
| `content-type` | yes | `application/json` |
|
||||
| `content-encoding` | yes | `utf-8` |
|
||||
| `x-event-type` | yes | Same as `event_type` (for routing convenience) |
|
||||
| `x-tenant-id` | yes | For tenant-aware consumers |
|
||||
| `x-trace-id` | yes | Distributed tracing |
|
||||
| `x-retry-count` | optional | Incremented on requeue |
|
||||
| `x-max-retries` | optional | Default 3 |
|
||||
|
||||
## Routing keys (topic exchange `flatrender.events`)
|
||||
|
||||
```
|
||||
identity.user.registered.v1
|
||||
identity.user.email_verified.v1
|
||||
identity.user.banned.v1
|
||||
identity.tenant.created.v1
|
||||
identity.tenant.suspended.v1
|
||||
identity.plan.activated.v1
|
||||
identity.plan.expired.v1
|
||||
identity.payment.succeeded.v1
|
||||
identity.payment.failed.v1
|
||||
identity.payment.refunded.v1
|
||||
identity.api_key.created.v1
|
||||
identity.api_key.revoked.v1
|
||||
|
||||
content.template.published.v1
|
||||
content.template.unpublished.v1
|
||||
content.font.installed.v1
|
||||
content.svg_preview.generated.v1
|
||||
|
||||
studio.project.saved.v1
|
||||
studio.project.deleted.v1
|
||||
|
||||
render.job.queued.v1
|
||||
render.job.started.v1
|
||||
render.job.progress.v1
|
||||
render.job.completed.v1
|
||||
render.job.failed.v1
|
||||
render.job.cancelled.v1
|
||||
render.snapshot.requested.v1
|
||||
render.snapshot.ready.v1
|
||||
|
||||
node.online.v1
|
||||
node.offline.v1
|
||||
node.crashed.v1
|
||||
node.heartbeat.v1
|
||||
node.cache.updated.v1
|
||||
|
||||
file.uploaded.v1
|
||||
file.processed.v1
|
||||
file.deleted.v1
|
||||
file.quota_warning.v1
|
||||
file.quota_exceeded.v1
|
||||
file.cleanup.scheduled.v1
|
||||
file.cleanup.executed.v1
|
||||
|
||||
notification.created.v1
|
||||
notification.delivered.v1
|
||||
notification.failed.v1
|
||||
|
||||
tenant.usage.recorded.v1
|
||||
tenant.webhook.fired.v1
|
||||
tenant.webhook.failed.v1
|
||||
```
|
||||
|
||||
## Render dispatch (direct exchange `flatrender.render`)
|
||||
|
||||
```
|
||||
render.queue.snapshot
|
||||
render.queue.vip
|
||||
render.queue.paid
|
||||
render.queue.preview
|
||||
render.queue.mockup
|
||||
render.queue.voiceover
|
||||
```
|
||||
|
||||
Each queue has priority `x-max-priority: 10`. Job priority encoded in
|
||||
message priority property.
|
||||
|
||||
## Notification dispatch (direct exchange `flatrender.notify`)
|
||||
|
||||
```
|
||||
notify.channel.push
|
||||
notify.channel.email
|
||||
notify.channel.sms
|
||||
notify.channel.telegram
|
||||
notify.channel.webhook
|
||||
```
|
||||
|
||||
## Dead-letter routing
|
||||
|
||||
Failed messages (after `x-max-retries` exhausted) are republished to
|
||||
`flatrender.dlq` with original routing key preserved in
|
||||
`x-original-routing-key` header.
|
||||
@@ -0,0 +1,61 @@
|
||||
# =====================================================================
|
||||
# Content Events — published by Content Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
content.template.published.v1:
|
||||
routing_key: content.template.published.v1
|
||||
description: A template (project_container) was published.
|
||||
payload:
|
||||
type: object
|
||||
required: [container_id, slug, primary_mode]
|
||||
properties:
|
||||
container_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
slug: { type: string }
|
||||
name: { type: string }
|
||||
primary_mode: { type: string }
|
||||
project_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
content.template.unpublished.v1:
|
||||
routing_key: content.template.unpublished.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [container_id, reason]
|
||||
properties:
|
||||
container_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
slug: { type: string }
|
||||
reason: { type: string }
|
||||
|
||||
content.font.installed.v1:
|
||||
routing_key: content.font.installed.v1
|
||||
description: Font registered as installed on render nodes.
|
||||
payload:
|
||||
type: object
|
||||
required: [font_id, name, system_name, node_ids]
|
||||
properties:
|
||||
font_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
system_name: { type: string }
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
content.svg_preview.generated.v1:
|
||||
routing_key: content.svg_preview.generated.v1
|
||||
description: AI service produced an SVG color preview for a scene/project.
|
||||
payload:
|
||||
type: object
|
||||
required: [svg_preview_id, svg_url, color_zones_count]
|
||||
properties:
|
||||
svg_preview_id: { type: string, format: uuid }
|
||||
project_id: { type: string, format: uuid, nullable: true }
|
||||
scene_id: { type: string, format: uuid, nullable: true }
|
||||
svg_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
color_zones_count: { type: integer }
|
||||
quality_score: { type: number, minimum: 0, maximum: 1 }
|
||||
generated_by_ai: { type: boolean }
|
||||
@@ -0,0 +1,114 @@
|
||||
# =====================================================================
|
||||
# File Events — published by File Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
file.uploaded.v1:
|
||||
routing_key: file.uploaded.v1
|
||||
description: A file upload has completed and is ready to use.
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, user_id, file_type, size_bytes, url]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
folder_id: { type: string, format: uuid, nullable: true }
|
||||
name: { type: string }
|
||||
file_type: { type: string, enum: [Video, Image, Audio, Voiceover, Document, Other] }
|
||||
mime_type: { type: string }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
duration_sec: { type: number, nullable: true }
|
||||
width: { type: integer, nullable: true }
|
||||
height: { type: integer, nullable: true }
|
||||
source:
|
||||
type: string
|
||||
enum: [upload, export, snapshot, voiceover_record, stock]
|
||||
|
||||
file.processed.v1:
|
||||
routing_key: file.processed.v1
|
||||
description: Post-upload processing (thumbnail, waveform, transcode) finished.
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, status]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Ready, Failed, Quarantined] }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
waveform_generated: { type: boolean }
|
||||
duration_sec: { type: number, nullable: true }
|
||||
error_message: { type: string, nullable: true }
|
||||
|
||||
file.deleted.v1:
|
||||
routing_key: file.deleted.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [file_id, user_id, size_bytes_freed]
|
||||
properties:
|
||||
file_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
size_bytes_freed: { type: integer, format: int64 }
|
||||
deleted_by:
|
||||
type: string
|
||||
enum: [user, auto_cleanup, admin, quota_exceeded]
|
||||
|
||||
file.quota_warning.v1:
|
||||
routing_key: file.quota_warning.v1
|
||||
description: User passed 90% of storage quota.
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, used_bytes, quota_bytes, percent_used]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
used_bytes: { type: integer, format: int64 }
|
||||
quota_bytes: { type: integer, format: int64 }
|
||||
percent_used: { type: number }
|
||||
|
||||
file.quota_exceeded.v1:
|
||||
routing_key: file.quota_exceeded.v1
|
||||
description: User hit 100% storage quota — uploads blocked.
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, used_bytes, quota_bytes]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
used_bytes: { type: integer, format: int64 }
|
||||
quota_bytes: { type: integer, format: int64 }
|
||||
attempted_upload_size_bytes: { type: integer, format: int64, nullable: true }
|
||||
|
||||
file.cleanup.scheduled.v1:
|
||||
routing_key: file.cleanup.scheduled.v1
|
||||
description: An entity has been queued for automatic deletion.
|
||||
payload:
|
||||
type: object
|
||||
required: [cleanup_id, entity_type, entity_id, scheduled_delete_at]
|
||||
properties:
|
||||
cleanup_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid, nullable: true }
|
||||
user_id: { type: string, format: uuid, nullable: true }
|
||||
entity_type:
|
||||
type: string
|
||||
enum: [Export, TempRenderFolder, OrphanedFile, UnusedUpload, SnapshotExpired]
|
||||
entity_id: { type: string, format: uuid }
|
||||
scheduled_delete_at: { type: string, format: date-time }
|
||||
notify_user_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
file.cleanup.executed.v1:
|
||||
routing_key: file.cleanup.executed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [cleanup_id, status, bytes_freed]
|
||||
properties:
|
||||
cleanup_id: { type: string, format: uuid }
|
||||
entity_type: { type: string }
|
||||
entity_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Done, Skipped, Failed] }
|
||||
bytes_freed: { type: integer, format: int64 }
|
||||
error_message: { type: string, nullable: true }
|
||||
@@ -0,0 +1,177 @@
|
||||
# =====================================================================
|
||||
# Identity Events — published by Identity Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
identity.user.registered.v1:
|
||||
routing_key: identity.user.registered.v1
|
||||
description: New user account created (any tenant).
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, tenant_id, register_mode]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
email: { type: string, nullable: true }
|
||||
phone_number: { type: string, nullable: true }
|
||||
full_name: { type: string, nullable: true }
|
||||
register_mode:
|
||||
type: string
|
||||
enum: [Email, Mobile, Google, Telegram, SSO, Reseller]
|
||||
affiliate_owner_id: { type: string, format: uuid, nullable: true }
|
||||
registered_with_mobile_app: { type: boolean }
|
||||
|
||||
identity.user.email_verified.v1:
|
||||
routing_key: identity.user.email_verified.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, email]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
email: { type: string }
|
||||
|
||||
identity.user.banned.v1:
|
||||
routing_key: identity.user.banned.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, banned_by, reason]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
banned_by: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
unblock_date: { type: string, format: date-time, nullable: true }
|
||||
|
||||
identity.tenant.created.v1:
|
||||
routing_key: identity.tenant.created.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, slug, kind]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
slug: { type: string }
|
||||
name: { type: string }
|
||||
kind: { type: string, enum: [Internal, Reseller, Enterprise] }
|
||||
contact_email: { type: string, nullable: true }
|
||||
|
||||
identity.tenant.suspended.v1:
|
||||
routing_key: identity.tenant.suspended.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, reason]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
suspended_at: { type: string, format: date-time }
|
||||
|
||||
identity.plan.activated.v1:
|
||||
routing_key: identity.plan.activated.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, user_plan_id, plan_code, expires_at]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_plan_id: { type: string, format: uuid }
|
||||
plan_id: { type: string, format: uuid }
|
||||
plan_code: { type: string }
|
||||
plan_name: { type: string }
|
||||
seconds_charge: { type: integer }
|
||||
storage_gb: { type: integer }
|
||||
parallel_renders: { type: integer }
|
||||
expires_at: { type: string, format: date-time }
|
||||
payment_id: { type: string, format: uuid, nullable: true }
|
||||
|
||||
identity.plan.expired.v1:
|
||||
routing_key: identity.plan.expired.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [user_id, user_plan_id]
|
||||
properties:
|
||||
user_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_plan_id: { type: string, format: uuid }
|
||||
plan_code: { type: string }
|
||||
days_overdue: { type: integer }
|
||||
|
||||
identity.payment.succeeded.v1:
|
||||
routing_key: identity.payment.succeeded.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, amount_minor, gateway, action]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
gateway:
|
||||
type: string
|
||||
enum: [ZarinPal, IdPay, Bazaar, Stripe, Balance, Manual]
|
||||
action:
|
||||
type: string
|
||||
enum: [PlanPurchase, BalanceCharge, ProjectRender, UserProject, StorageUpgrade, Other]
|
||||
gateway_track_id: { type: string, nullable: true }
|
||||
plan_id: { type: string, format: uuid, nullable: true }
|
||||
affiliate_owner_id: { type: string, format: uuid, nullable: true }
|
||||
affiliate_profit_minor: { type: integer, format: int64 }
|
||||
|
||||
identity.payment.failed.v1:
|
||||
routing_key: identity.payment.failed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, gateway, failure_reason]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
gateway: { type: string }
|
||||
failure_reason: { type: string }
|
||||
action: { type: string }
|
||||
|
||||
identity.payment.refunded.v1:
|
||||
routing_key: identity.payment.refunded.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [payment_id, user_id, refund_amount_minor, reason]
|
||||
properties:
|
||||
payment_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
refund_amount_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
reason: { type: string }
|
||||
refunded_to:
|
||||
type: string
|
||||
enum: [Balance, OriginalMethod, Plan]
|
||||
|
||||
identity.api_key.created.v1:
|
||||
routing_key: identity.api_key.created.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [api_key_id, tenant_id, name, environment, created_by_user_id]
|
||||
properties:
|
||||
api_key_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
environment: { type: string, enum: [Live, Test] }
|
||||
key_prefix: { type: string }
|
||||
last4: { type: string }
|
||||
scopes:
|
||||
type: array
|
||||
items: { type: string }
|
||||
created_by_user_id: { type: string, format: uuid }
|
||||
|
||||
identity.api_key.revoked.v1:
|
||||
routing_key: identity.api_key.revoked.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [api_key_id, tenant_id, revoked_by_user_id, reason]
|
||||
properties:
|
||||
api_key_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
revoked_by_user_id: { type: string, format: uuid }
|
||||
reason: { type: string }
|
||||
@@ -0,0 +1,122 @@
|
||||
# =====================================================================
|
||||
# Node Events — published by Node Agent → Render Orchestrator
|
||||
# Routing: flatrender.events (topic) — key: node.*.v1
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.online.v1 — node agent starts up
|
||||
# -------------------------------------------------------------------
|
||||
node.online.v1:
|
||||
routing_key: node.online.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, region, node_agent_version, current_ae_version]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
node_ip: { type: string, format: ipv4 }
|
||||
region: { type: string }
|
||||
node_agent_version: { type: string }
|
||||
current_ae_version: { type: string }
|
||||
available_ae_versions:
|
||||
type: array
|
||||
items: { type: string }
|
||||
ram_gb: { type: integer }
|
||||
cpu_cores: { type: integer }
|
||||
cache_used_gb: { type: integer }
|
||||
cached_template_md5s:
|
||||
type: array
|
||||
items: { type: string }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.offline.v1 — graceful shutdown OR detected missed heartbeats
|
||||
# -------------------------------------------------------------------
|
||||
node.offline.v1:
|
||||
routing_key: node.offline.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, reason]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
reason: { type: string, enum: [Shutdown, HeartbeatLost, Maintenance, Disabled] }
|
||||
last_heartbeat_at: { type: string, format: date-time }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.heartbeat.v1 — every 5s (NOT broadcast on topic exchange,
|
||||
# sent direct to orchestrator HTTP endpoint OR a dedicated stream)
|
||||
# Documented here for completeness.
|
||||
# -------------------------------------------------------------------
|
||||
node.heartbeat.v1:
|
||||
routing_key: node.heartbeat.v1
|
||||
transport: HTTP POST /v1/internal/nodes/{node_id}/heartbeat
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, status, recorded_at]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
status: { type: string, enum: [Ready, Busy, Crashed, Updating] }
|
||||
recorded_at: { type: string, format: date-time }
|
||||
cpu_pct: { type: integer, minimum: 0, maximum: 100 }
|
||||
ram_available_mb: { type: integer }
|
||||
ae_running: { type: boolean }
|
||||
current_job_id: { type: string, format: uuid, nullable: true }
|
||||
current_frame_job_id: { type: string, format: uuid, nullable: true }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
cache_used_gb: { type: integer }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.crashed.v1 — AfterFX crashed mid-render
|
||||
# -------------------------------------------------------------------
|
||||
node.crashed.v1:
|
||||
routing_key: node.crashed.v1
|
||||
description: AE process exited unexpectedly while rendering.
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, crashed_at]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
render_job_id: { type: string, format: uuid, nullable: true }
|
||||
frame_job_id: { type: string, format: uuid, nullable: true }
|
||||
crashed_at: { type: string, format: date-time }
|
||||
last_known_frame: { type: integer, nullable: true }
|
||||
crash_signal: { type: string, nullable: true }
|
||||
ae_version: { type: string }
|
||||
error_log_tail: { type: string, description: "Last ~50 lines of AE log" }
|
||||
log_file_url: { type: string, nullable: true }
|
||||
auto_recovery_started: { type: boolean }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.cache.updated.v1 — template cache changed (download or evict)
|
||||
# -------------------------------------------------------------------
|
||||
node.cache.updated.v1:
|
||||
routing_key: node.cache.updated.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [node_id, action, project_id, aep_file_md5]
|
||||
properties:
|
||||
node_id: { type: string, format: uuid }
|
||||
action: { type: string, enum: [Downloaded, Evicted, Verified, Failed] }
|
||||
project_id: { type: string, format: uuid }
|
||||
aep_file_md5: { type: string }
|
||||
file_size_bytes: { type: integer, format: int64 }
|
||||
cache_used_gb: { type: integer, description: "Total cache size after action" }
|
||||
duration_ms: { type: integer, nullable: true }
|
||||
error_message: { type: string, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# node.frame.completed.v1 — single frame done (high-frequency)
|
||||
# NOT on topic; sent via direct push to orchestrator.
|
||||
# -------------------------------------------------------------------
|
||||
node.frame.completed.v1:
|
||||
routing_key: node.frame.completed.v1
|
||||
transport: HTTP POST /v1/internal/render/jobs/{job_id}/frames
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, frame_job_id, frame_number]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
frame_job_id: { type: string, format: uuid }
|
||||
frame_number: { type: integer }
|
||||
file_size_bytes: { type: integer }
|
||||
completed_at: { type: string, format: date-time }
|
||||
@@ -0,0 +1,66 @@
|
||||
# =====================================================================
|
||||
# Notification Events
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
notification.created.v1:
|
||||
routing_key: notification.created.v1
|
||||
description: A new in-app notification was created (also fans out to channels).
|
||||
payload:
|
||||
type: object
|
||||
required: [notification_id, user_id, notification_type, title, message]
|
||||
properties:
|
||||
notification_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
notification_type:
|
||||
type: string
|
||||
enum: [RenderCompleted, RenderFailed, RenderProgress,
|
||||
PlanExpiring, PlanExpired, PaymentSuccess, PaymentFailed,
|
||||
StorageWarning, StorageFull, ExportExpiring, ExportDeleted,
|
||||
GiftEarned, QuestCompleted, LevelUp,
|
||||
AccountSecurity, SystemAnnouncement, TenantInvite,
|
||||
Marketing, Other]
|
||||
priority: { type: string, enum: [Low, Normal, High, Urgent] }
|
||||
title: { type: string }
|
||||
message: { type: string }
|
||||
action_url: { type: string, nullable: true }
|
||||
# Fan-out routing — which channels to deliver to
|
||||
channels:
|
||||
type: array
|
||||
items: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
|
||||
|
||||
notification.delivered.v1:
|
||||
routing_key: notification.delivered.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [delivery_id, notification_id, channel, status]
|
||||
properties:
|
||||
delivery_id: { type: string, format: uuid }
|
||||
notification_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
channel: { type: string, enum: [InApp, Push, Email, SMS, Telegram, Webhook] }
|
||||
status: { type: string, enum: [Sent, Delivered] }
|
||||
provider: { type: string }
|
||||
provider_message_id: { type: string, nullable: true }
|
||||
sent_at: { type: string, format: date-time }
|
||||
delivered_at: { type: string, format: date-time, nullable: true }
|
||||
|
||||
notification.failed.v1:
|
||||
routing_key: notification.failed.v1
|
||||
payload:
|
||||
type: object
|
||||
required: [delivery_id, channel, error_message]
|
||||
properties:
|
||||
delivery_id: { type: string, format: uuid }
|
||||
notification_id: { type: string, format: uuid, nullable: true }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
channel: { type: string }
|
||||
attempt: { type: integer }
|
||||
max_attempts: { type: integer }
|
||||
error_code: { type: string, nullable: true }
|
||||
error_message: { type: string }
|
||||
will_retry: { type: boolean }
|
||||
next_retry_at: { type: string, format: date-time, nullable: true }
|
||||
@@ -0,0 +1,219 @@
|
||||
# =====================================================================
|
||||
# Render Events — published by Render Orchestrator
|
||||
# Routing: flatrender.events (topic) — key: render.*.v1
|
||||
# =====================================================================
|
||||
$schema: "http://json-schema.org/draft-07/schema#"
|
||||
title: Render Event Schemas
|
||||
type: object
|
||||
|
||||
definitions:
|
||||
EventEnvelope:
|
||||
type: object
|
||||
required: [event_id, event_type, event_time, tenant_id, data]
|
||||
properties:
|
||||
event_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
event_time: { type: string, format: date-time }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
trace_id: { type: string, format: uuid }
|
||||
correlation_id: { type: string, format: uuid }
|
||||
producer: { type: string, const: render-orchestrator }
|
||||
data: { type: object }
|
||||
|
||||
events:
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.queued.v1
|
||||
# Emitted when a job is accepted into the queue.
|
||||
# Consumed by: notification (none yet), analytics
|
||||
# -------------------------------------------------------------------
|
||||
render.job.queued.v1:
|
||||
routing_key: render.job.queued.v1
|
||||
description: New render job has been accepted into a priority queue.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, saved_project_id, priority_queue, price_type, region]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
priority_queue:
|
||||
type: string
|
||||
enum: [snapshot, vip, paid, preview, mockup, voiceover]
|
||||
priority_score: { type: integer, minimum: 0, maximum: 100 }
|
||||
price_type:
|
||||
type: string
|
||||
enum: [Free, Preview, Cash, Plan, Snapshot, Reseller]
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
region: { type: string }
|
||||
duration_sec: { type: number }
|
||||
resolution: { type: string }
|
||||
is_60_fps: { type: boolean }
|
||||
has_music: { type: boolean }
|
||||
has_sfx: { type: boolean }
|
||||
has_voiceover: { type: boolean }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.started.v1
|
||||
# Emitted once nodes are assigned and rendering begins.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.started.v1:
|
||||
routing_key: render.job.started.v1
|
||||
description: Render job has begun processing on at least one node.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, started_at]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
started_at: { type: string, format: date-time }
|
||||
node_ids:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
region: { type: string }
|
||||
total_frames: { type: integer }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.progress.v1 (high-frequency, optional pub/sub)
|
||||
# NOT broadcast on the main events exchange — only on per-job
|
||||
# ephemeral fanout for WebSocket fan-out. Schema documented here.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.progress.v1:
|
||||
routing_key: render.job.progress.{job_id}
|
||||
description: Progress tick for live UI update.
|
||||
exchange: render.progress # separate dedicated exchange (auto-delete fanout)
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, step, progress]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
step:
|
||||
type: string
|
||||
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
|
||||
Rendering, Validating, Repairing, Optimisation, Video,
|
||||
Mixing, Final, Uploading, Done, Failed, Cancelled]
|
||||
progress: { type: integer, minimum: 0, maximum: 100 }
|
||||
current_frame: { type: integer, nullable: true }
|
||||
total_frames: { type: integer, nullable: true }
|
||||
eta_seconds: { type: integer, nullable: true }
|
||||
preview_b64:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Last rendered frame thumbnail (small, ~5-15 KB)
|
||||
message: { type: string, nullable: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.completed.v1
|
||||
# Consumed by: notification, studio (mark project), file (cleanup),
|
||||
# tenant (usage metering), webhook dispatcher
|
||||
# -------------------------------------------------------------------
|
||||
render.job.completed.v1:
|
||||
routing_key: render.job.completed.v1
|
||||
description: Render job successfully produced an export.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, export_id, output_url, duration_sec, size_bytes]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
export_id: { type: string, format: uuid }
|
||||
output_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
duration_sec: { type: number }
|
||||
size_bytes: { type: integer, format: int64 }
|
||||
resolution: { type: string }
|
||||
width: { type: integer }
|
||||
height: { type: integer }
|
||||
render_compute_seconds: { type: integer, description: "Sum of node-seconds spent" }
|
||||
node_ids_used:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
price_type: { type: string }
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.failed.v1
|
||||
# Consumed by: notification, identity (refund), tenant (metering)
|
||||
# -------------------------------------------------------------------
|
||||
render.job.failed.v1:
|
||||
routing_key: render.job.failed.v1
|
||||
description: Render job failed permanently (after retries exhausted).
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, failed_at_step, error_message]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
failed_at_step:
|
||||
type: string
|
||||
enum: [Queued, Preparing, TemplateCache, JsxGen, Music,
|
||||
Rendering, Validating, Repairing, Optimisation, Video,
|
||||
Mixing, Final, Uploading, Done, Failed, Cancelled]
|
||||
error_message: { type: string }
|
||||
error_code: { type: string, nullable: true }
|
||||
retry_count: { type: integer }
|
||||
refund_required: { type: boolean, default: true }
|
||||
price_type: { type: string }
|
||||
paid_price_minor: { type: integer, format: int64 }
|
||||
node_ids_attempted:
|
||||
type: array
|
||||
items: { type: string, format: uuid }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.job.cancelled.v1
|
||||
# User cancelled via UI before completion.
|
||||
# -------------------------------------------------------------------
|
||||
render.job.cancelled.v1:
|
||||
routing_key: render.job.cancelled.v1
|
||||
description: Render job was cancelled by user.
|
||||
payload:
|
||||
type: object
|
||||
required: [render_job_id, cancelled_by_user_id]
|
||||
properties:
|
||||
render_job_id: { type: string, format: uuid }
|
||||
cancelled_by_user_id: { type: string, format: uuid }
|
||||
cancelled_at_step: { type: string }
|
||||
progress_when_cancelled: { type: integer }
|
||||
refund_required: { type: boolean, default: true }
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.snapshot.requested.v1
|
||||
# User asked for a single-frame snapshot of a scene.
|
||||
# -------------------------------------------------------------------
|
||||
render.snapshot.requested.v1:
|
||||
routing_key: render.snapshot.requested.v1
|
||||
description: Scene snapshot was queued.
|
||||
payload:
|
||||
type: object
|
||||
required: [snapshot_id, saved_project_id, scene_key, frame_number]
|
||||
properties:
|
||||
snapshot_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer }
|
||||
cached:
|
||||
type: boolean
|
||||
description: True if served from cache (no render needed)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# render.snapshot.ready.v1
|
||||
# Single-frame snapshot finished.
|
||||
# -------------------------------------------------------------------
|
||||
render.snapshot.ready.v1:
|
||||
routing_key: render.snapshot.ready.v1
|
||||
description: Snapshot image is ready.
|
||||
payload:
|
||||
type: object
|
||||
required: [snapshot_id, image_url]
|
||||
properties:
|
||||
snapshot_id: { type: string, format: uuid }
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
scene_key: { type: string }
|
||||
frame_number: { type: integer }
|
||||
image_url: { type: string }
|
||||
thumbnail_url: { type: string, nullable: true }
|
||||
width: { type: integer }
|
||||
height: { type: integer }
|
||||
size_bytes: { type: integer }
|
||||
duration_ms: { type: integer }
|
||||
expires_at: { type: string, format: date-time }
|
||||
@@ -0,0 +1,32 @@
|
||||
# =====================================================================
|
||||
# Studio Events — published by Studio Service
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
studio.project.saved.v1:
|
||||
routing_key: studio.project.saved.v1
|
||||
description: User saved their project (used to trigger autosave events, analytics).
|
||||
payload:
|
||||
type: object
|
||||
required: [saved_project_id, user_id]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
original_project_id: { type: string, format: uuid }
|
||||
name: { type: string }
|
||||
choose_mode: { type: string }
|
||||
scene_count: { type: integer }
|
||||
is_first_save: { type: boolean }
|
||||
|
||||
studio.project.deleted.v1:
|
||||
routing_key: studio.project.deleted.v1
|
||||
description: User deleted a saved project (moved to trash or hard-deleted).
|
||||
payload:
|
||||
type: object
|
||||
required: [saved_project_id, user_id, hard_delete]
|
||||
properties:
|
||||
saved_project_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
user_id: { type: string, format: uuid }
|
||||
hard_delete: { type: boolean }
|
||||
@@ -0,0 +1,72 @@
|
||||
# =====================================================================
|
||||
# Tenant Events — multi-tenancy / reseller-specific
|
||||
# =====================================================================
|
||||
events:
|
||||
|
||||
tenant.usage.recorded.v1:
|
||||
routing_key: tenant.usage.recorded.v1
|
||||
description: Daily usage aggregate published (by usage aggregator cron).
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, usage_date, renders_completed, render_seconds]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
usage_date: { type: string, format: date }
|
||||
renders_started: { type: integer }
|
||||
renders_completed: { type: integer }
|
||||
renders_failed: { type: integer }
|
||||
render_seconds: { type: integer, format: int64 }
|
||||
render_compute_sec: { type: integer, format: int64 }
|
||||
storage_bytes: { type: integer, format: int64 }
|
||||
api_calls: { type: integer, format: int64 }
|
||||
active_users: { type: integer }
|
||||
new_users: { type: integer }
|
||||
amount_billed_minor: { type: integer, format: int64 }
|
||||
currency: { type: string }
|
||||
|
||||
tenant.webhook.fired.v1:
|
||||
routing_key: tenant.webhook.fired.v1
|
||||
description: A webhook was successfully delivered to a reseller.
|
||||
payload:
|
||||
type: object
|
||||
required: [webhook_id, tenant_id, event_type, response_status]
|
||||
properties:
|
||||
webhook_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
delivery_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
request_url: { type: string }
|
||||
response_status: { type: integer }
|
||||
duration_ms: { type: integer }
|
||||
attempt: { type: integer }
|
||||
|
||||
tenant.webhook.failed.v1:
|
||||
routing_key: tenant.webhook.failed.v1
|
||||
description: A webhook delivery exhausted retries.
|
||||
payload:
|
||||
type: object
|
||||
required: [webhook_id, tenant_id, event_type, last_status, last_error]
|
||||
properties:
|
||||
webhook_id: { type: string, format: uuid }
|
||||
tenant_id: { type: string, format: uuid }
|
||||
delivery_id: { type: string, format: uuid }
|
||||
event_type: { type: string }
|
||||
request_url: { type: string }
|
||||
last_status: { type: integer, nullable: true }
|
||||
last_error: { type: string }
|
||||
attempts: { type: integer }
|
||||
webhook_disabled: { type: boolean, description: "True if auto-disabled" }
|
||||
|
||||
tenant.api.rate_limited.v1:
|
||||
routing_key: tenant.api.rate_limited.v1
|
||||
description: A tenant exceeded its API rate limit (informational).
|
||||
payload:
|
||||
type: object
|
||||
required: [tenant_id, api_key_id, limit_rpm, window_start]
|
||||
properties:
|
||||
tenant_id: { type: string, format: uuid }
|
||||
api_key_id: { type: string, format: uuid }
|
||||
limit_rpm: { type: integer }
|
||||
actual_rpm: { type: integer }
|
||||
window_start: { type: string, format: date-time }
|
||||
ip_address: { type: string }
|
||||
Reference in New Issue
Block a user