diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a796c7b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "gopls-lsp@claude-plugins-official": true + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7758a48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Quick Commands + +```bash +# Run server +go run ./cmd/server + +# Build server +go build ./cmd/server + +# Run all tests +go test ./... + +# Run service/middleware tests only +go test ./internal/service/... ./internal/middleware/... + +# Regenerate GORM models and queries (after DB schema changes) +go run ./cmd/gendb + +# Lint protobufs +buf lint + +# Generate protobuf code (Go + TypeScript) +buf generate + +# Run DB migrations +./run_migration.sh +``` + +## Architecture Overview + +**Module**: `stream.api` (Go 1.25.6) + +This is a **gRPC-first backend** for a video streaming platform with agent/render orchestration. + +### Layer Structure + +``` +cmd/ → Entrypoints (server, gendb, worker stub) +internal/ + transport/grpc → gRPC server, agent runtime, streaming, auth + service → Business logic, composed via appServices struct + repository → Database access layer (transactions, GORM abstraction) + database/ + model/ → Generated GORM models (*.gen.go) + query/ → Generated GORM query builders (*.gen.go) + adapters/redis → Redis queue, pub/sub for jobs/agents + middleware → gRPC metadata-based internal auth + dto → Data transfer objects + api/proto → Generated protobuf Go code +proto/ → Source .proto files (app/v1, agent/v1) +pkg/ → Shared utilities (database, logger, storage, auth) +``` + +### Key Patterns + +- **Service composition**: `internal/service/service_core.go` defines `appServices` struct holding all dependencies; typed services (`authAppService`, `paymentsAppService`, etc.) embed it +- **Repository abstraction**: Services depend on repository interfaces, not raw GORM +- **Generated DB layer**: `cmd/gendb/main.go` reads schema and generates models/queries +- **Internal auth**: gRPC metadata (`x-stream-internal-auth`, `x-stream-actor-*`) for service-to-service auth +- **Transaction boundaries**: Repositories handle transactions for multi-entity operations (payments, account deletion, subscription updates) +- **Job/render subsystem**: Jobs in DB + Redis sorted set queue + pub/sub for logs/cancels/updates + MQTT for agent events + +### Test Patterns + +- In-memory SQLite for service tests (`internal/service/__test__/testdb_setup_test.go`) +- bufconn for gRPC test servers +- Tests isolated and fast, no external Postgres required + +## Important Caveats + +- **Stale paths in docs/scripts**: `MIGRATION_GUIDE.md` and some scripts reference `cmd/api` or `cmd/grpc` — the actual entrypoint is `cmd/server` +- **Dockerfile**: References `./cmd/grpc` which doesn't exist; likely needs updating to `./cmd/server` +- **Proto path mismatch**: `proto/app/v1/admin.proto` declares `go_package` as `internal/gen/proto/...` but generated code lands in `internal/api/proto/...` + +## Notification Settings + +- Email: disabled +- Notifications: disabled diff --git a/docs/docs.go b/docs/docs.go deleted file mode 100644 index 32061d5..0000000 --- a/docs/docs.go +++ /dev/null @@ -1,4251 +0,0 @@ -//go:build ignore -// +build ignore - -// Package docs Code generated by swaggo/swag. DO NOT EDIT -package docs - -import "github.com/swaggo/swag" - -const docTemplate = `{ - "schemes": {{ marshal .Schemes }}, - "swagger": "2.0", - "info": { - "description": "{{escape .Description}}", - "title": "{{.Title}}", - "termsOfService": "http://swagger.io/terms/", - "contact": { - "name": "API Support", - "url": "http://www.swagger.io/support", - "email": "support@swagger.io" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version": "{{.Version}}" - }, - "host": "{{.Host}}", - "basePath": "{{.BasePath}}", - "paths": { - "/ad-templates": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all VAST ad templates for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "List Ad Templates", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplateListPayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a VAST ad template for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Create Ad Template", - "parameters": [ - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/adtemplates.SaveAdTemplateRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplatePayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/ad-templates/{id}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a VAST ad template for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Update Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/adtemplates.SaveAdTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplatePayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a VAST ad template for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Delete Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ad-templates": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all ad templates across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Ad Templates", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Search by name", - "name": "search", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create an ad template for any user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Ad Template", - "parameters": [ - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminAdTemplateRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ad-templates/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get ad template detail (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Ad Template Detail", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update an ad template for any user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminAdTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete any ad template by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Ad Template (Admin)", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns currently connected render agents and current runtime stats", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "List connected render agents", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents/{id}/restart": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Sends a restart command to a currently connected render agent", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Restart connected render agent", - "parameters": [ - { - "type": "string", - "description": "Agent ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents/{id}/update": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Sends an update command to a currently connected render agent", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Update connected render agent", - "parameters": [ - { - "type": "string", - "description": "Agent ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/dashboard": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get system-wide statistics for the admin dashboard", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Admin Dashboard", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/admin.DashboardPayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns paginated render jobs for admin management", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "List render jobs", - "parameters": [ - { - "type": "integer", - "description": "Offset", - "name": "offset", - "in": "query" - }, - { - "type": "integer", - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Agent ID", - "name": "agent_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Queues a new render job for agents", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Create render job", - "parameters": [ - { - "description": "Job payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.createJobRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns a render job by ID", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Get render job detail", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/cancel": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Cancels a pending or running render job", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Cancel render job", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/logs": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns plain text logs for a render job", - "produces": [ - "text/plain" - ], - "tags": [ - "admin-render" - ], - "summary": "Get render job logs", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/retry": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Retries a failed or cancelled render job", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Retry render job", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/payments": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all payments across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Payments", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by status", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a model subscription charge for a user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Payment", - "parameters": [ - { - "description": "Payment payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.CreateAdminPaymentRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/payments/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get payment detail (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Payment Detail", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update payment status safely without hard delete (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Payment", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Payment update payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateAdminPaymentRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/plans": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all plans with usage counts (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List Plans", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a plan (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Plan", - "parameters": [ - { - "description": "Plan payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SavePlanRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/plans/{id}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a plan (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Plan", - "parameters": [ - { - "type": "string", - "description": "Plan ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Plan payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SavePlanRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a plan, or deactivate it if already used (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Plan", - "parameters": [ - { - "type": "string", - "description": "Plan ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List Users", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Search by email or username", - "name": "search", - "in": "query" - }, - { - "type": "string", - "description": "Filter by role", - "name": "role", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a user from admin panel (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create User", - "parameters": [ - { - "description": "User payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.CreateAdminUserRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get detailed info about a single user (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get User Detail", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a user from admin panel (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update User", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "User payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateAdminUserRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a user and their data (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete User", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users/{id}/role": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Change user role (admin only). Valid: USER, ADMIN, BLOCK", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update User Role", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Role payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateUserRoleRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/videos": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all videos across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Videos", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Search by title", - "name": "search", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by status", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a model video record for a user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Video", - "parameters": [ - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminVideoRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/videos/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get video detail by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Video Detail", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update video metadata and status (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminVideoRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete any video by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Video (Admin)", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ws": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Upgrade to websocket for authenticated admin realtime job and agent updates", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Admin realtime websocket", - "responses": { - "101": { - "description": "Switching Protocols", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/change-password": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Change the authenticated user's local password", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Change Password", - "parameters": [ - { - "description": "Password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ChangePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/forgot-password": { - "post": { - "description": "Request password reset link", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Forgot Password", - "parameters": [ - { - "description": "Forgot password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ForgotPasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/google/callback": { - "get": { - "description": "Callback for Google Login", - "tags": [ - "auth" - ], - "summary": "Google Callback", - "responses": { - "307": { - "description": "Temporary Redirect" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/google/login": { - "get": { - "description": "Redirect to Google for Login", - "tags": [ - "auth" - ], - "summary": "Google Login", - "responses": {} - } - }, - "/auth/login": { - "post": { - "description": "Login with email and password", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Login", - "parameters": [ - { - "description": "Login payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.LoginRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/auth.UserPayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/logout": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Logout user and clear cookies", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Logout", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/register": { - "post": { - "description": "Register a new user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Register", - "parameters": [ - { - "description": "Registration payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.RegisterRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/reset-password": { - "post": { - "description": "Reset password using token", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Reset Password", - "parameters": [ - { - "description": "Reset password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ResetPasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/domains": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all whitelisted domains for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "List Domains", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Add a domain to the current user's whitelist", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "Create Domain", - "parameters": [ - { - "description": "Domain payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domains.CreateDomainRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/domains/{id}": { - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Remove a domain from the current user's whitelist", - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "Delete Domain", - "parameters": [ - { - "type": "string", - "description": "Domain ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/health/detailed": { - "get": { - "description": "Returns detailed health state for database, redis, and render dependencies", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Detailed health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/services.HealthReport" - } - } - } - } - }, - "/health/live": { - "get": { - "description": "Returns liveness status for the API and render module", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Liveness health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/health/ready": { - "get": { - "description": "Returns readiness status including render gRPC availability flag", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Readiness health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/me": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get the authenticated user's profile payload", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Get Current User", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update the authenticated user's profile information", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Update Current User", - "parameters": [ - { - "description": "Profile payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.UpdateMeRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Permanently delete the authenticated user's account and related data", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Delete My Account", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/me/clear-data": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Remove videos and settings-related resources for the authenticated user", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Clear My Data", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get notifications for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "List Notifications", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete all notifications for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Clear Notifications", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/read-all": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Mark all notifications as read for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Mark All Notifications Read", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/{id}": { - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a single notification for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Delete Notification", - "parameters": [ - { - "type": "string", - "description": "Notification ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/{id}/read": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Mark a single notification as read for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Mark Notification Read", - "parameters": [ - { - "type": "string", - "description": "Notification ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a new payment for buying or renewing a plan", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "Create Payment", - "parameters": [ - { - "description": "Payment Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/payment.CreatePaymentRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments/history": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get payment history for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "List Payment History", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments/{id}/invoice": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Download invoice text for a payment or wallet top-up", - "produces": [ - "text/plain" - ], - "tags": [ - "payment" - ], - "summary": "Download Invoice", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/plans": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all active plans", - "produces": [ - "application/json" - ], - "tags": [ - "plan" - ], - "summary": "List Plans", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/settings/preferences": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get notification, player, and locale preferences for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "settings" - ], - "summary": "Get Preferences", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update notification, player, and locale preferences for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "settings" - ], - "summary": "Update Preferences", - "parameters": [ - { - "description": "Preferences payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/preferences.SettingsPreferencesRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/usage": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get the authenticated user's total video count and total storage usage", - "produces": [ - "application/json" - ], - "tags": [ - "usage" - ], - "summary": "Get Usage", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/usage.UsagePayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated videos", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "List Videos", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 10, - "description": "Page size", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create video record after upload", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Create Video", - "parameters": [ - { - "description": "Video Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.CreateVideoRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Video" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos/upload-url": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Generate presigned URL for video upload", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Get Upload URL", - "parameters": [ - { - "description": "File Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.UploadURLRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get video details by ID", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Get Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Video" - } - } - } - ] - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update title and description for a video owned by the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Update Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.UpdateVideoRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a video owned by the current user", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Delete Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/wallet/topups": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Add funds to wallet balance for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "Top Up Wallet", - "parameters": [ - { - "description": "Topup Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/payment.TopupWalletRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - } - }, - "definitions": { - "admin.CreateAdminPaymentRequest": { - "type": "object", - "required": [ - "payment_method", - "plan_id", - "term_months", - "user_id" - ], - "properties": { - "payment_method": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "term_months": { - "type": "integer" - }, - "topup_amount": { - "type": "number" - }, - "user_id": { - "type": "string" - } - } - }, - "admin.CreateAdminUserRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string", - "minLength": 6 - }, - "plan_id": { - "type": "string" - }, - "role": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "admin.DashboardPayload": { - "type": "object", - "properties": { - "active_subscriptions": { - "type": "integer" - }, - "new_users_today": { - "type": "integer" - }, - "new_videos_today": { - "type": "integer" - }, - "total_ad_templates": { - "type": "integer" - }, - "total_payments": { - "type": "integer" - }, - "total_revenue": { - "type": "number" - }, - "total_storage_used": { - "type": "integer" - }, - "total_users": { - "type": "integer" - }, - "total_videos": { - "type": "integer" - } - } - }, - "admin.SaveAdminAdTemplateRequest": { - "type": "object", - "required": [ - "name", - "user_id", - "vast_tag_url" - ], - "properties": { - "ad_format": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "admin.SaveAdminVideoRequest": { - "type": "object", - "required": [ - "size", - "title", - "url", - "user_id" - ], - "properties": { - "ad_template_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "format": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - }, - "user_id": { - "type": "string" - } - } - }, - "admin.SavePlanRequest": { - "type": "object", - "required": [ - "cycle", - "name", - "price", - "storage_limit", - "upload_limit" - ], - "properties": { - "cycle": { - "type": "string" - }, - "description": { - "type": "string" - }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "price": { - "type": "number" - }, - "storage_limit": { - "type": "integer" - }, - "upload_limit": { - "type": "integer" - } - } - }, - "admin.UpdateAdminPaymentRequest": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string" - } - } - }, - "admin.UpdateAdminUserRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "role": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "admin.UpdateUserRoleRequest": { - "type": "object", - "required": [ - "role" - ], - "properties": { - "role": { - "type": "string" - } - } - }, - "admin.createJobRequest": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "image": { - "type": "string" - }, - "name": { - "type": "string" - }, - "priority": { - "type": "integer" - }, - "time_limit": { - "type": "integer" - }, - "user_id": { - "type": "string" - } - } - }, - "adtemplates.SaveAdTemplateRequest": { - "type": "object", - "required": [ - "name", - "vast_tag_url" - ], - "properties": { - "ad_format": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "adtemplates.TemplateListPayload": { - "type": "object", - "properties": { - "templates": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AdTemplate" - } - } - } - }, - "adtemplates.TemplatePayload": { - "type": "object", - "properties": { - "template": { - "$ref": "#/definitions/model.AdTemplate" - } - } - }, - "auth.ChangePasswordRequest": { - "type": "object", - "required": [ - "current_password", - "new_password" - ], - "properties": { - "current_password": { - "type": "string" - }, - "new_password": { - "type": "string", - "minLength": 6 - } - } - }, - "auth.ForgotPasswordRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, - "auth.LoginRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, - "auth.RegisterRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string", - "minLength": 6 - }, - "username": { - "type": "string" - } - } - }, - "auth.ResetPasswordRequest": { - "type": "object", - "required": [ - "new_password", - "token" - ], - "properties": { - "new_password": { - "type": "string", - "minLength": 6 - }, - "token": { - "type": "string" - } - } - }, - "auth.UpdateMeRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "auth.UserPayload": { - "type": "object", - "properties": { - "avatar": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "google_id": { - "type": "string" - }, - "id": { - "type": "string" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "plan_expires_at": { - "type": "string" - }, - "plan_expiring_soon": { - "type": "boolean" - }, - "plan_id": { - "type": "string" - }, - "plan_payment_method": { - "type": "string" - }, - "plan_started_at": { - "type": "string" - }, - "plan_term_months": { - "type": "integer" - }, - "role": { - "type": "string" - }, - "storage_used": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "wallet_balance": { - "type": "number" - } - } - }, - "domains.CreateDomainRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - } - } - }, - "model.AdTemplate": { - "type": "object", - "properties": { - "ad_format": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "model.Video": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "format": { - "type": "string" - }, - "hls_path": { - "type": "string" - }, - "hls_token": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "processing_status": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "storage_type": { - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "views": { - "type": "integer" - } - } - }, - "payment.CreatePaymentRequest": { - "type": "object", - "required": [ - "payment_method", - "plan_id", - "term_months" - ], - "properties": { - "payment_method": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "term_months": { - "type": "integer" - }, - "topup_amount": { - "type": "number" - } - } - }, - "payment.TopupWalletRequest": { - "type": "object", - "required": [ - "amount" - ], - "properties": { - "amount": { - "type": "number" - } - } - }, - "preferences.SettingsPreferencesRequest": { - "type": "object", - "properties": { - "airplay": { - "type": "boolean" - }, - "autoplay": { - "type": "boolean" - }, - "chromecast": { - "type": "boolean" - }, - "email_notifications": { - "type": "boolean" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "loop": { - "type": "boolean" - }, - "marketing_notifications": { - "type": "boolean" - }, - "muted": { - "type": "boolean" - }, - "pip": { - "type": "boolean" - }, - "push_notifications": { - "type": "boolean" - }, - "show_controls": { - "type": "boolean" - }, - "telegram_notifications": { - "type": "boolean" - } - } - }, - "response.Response": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "data": {}, - "message": { - "type": "string" - } - } - }, - "services.ComponentHealth": { - "type": "object", - "properties": { - "checked_at": { - "type": "string" - }, - "latency": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/services.HealthStatus" - } - } - }, - "services.HealthReport": { - "type": "object", - "properties": { - "checked_at": { - "type": "string" - }, - "components": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/services.ComponentHealth" - } - }, - "status": { - "$ref": "#/definitions/services.HealthStatus" - }, - "version": { - "type": "string" - } - } - }, - "services.HealthStatus": { - "type": "string", - "enum": [ - "healthy", - "unhealthy", - "degraded" - ], - "x-enum-varnames": [ - "HealthStatusHealthy", - "HealthStatusUnhealthy", - "HealthStatusDegraded" - ] - }, - "usage.UsagePayload": { - "type": "object", - "properties": { - "total_storage": { - "type": "integer" - }, - "total_videos": { - "type": "integer" - }, - "user_id": { - "type": "string" - } - } - }, - "video.CreateVideoRequest": { - "type": "object", - "required": [ - "size", - "title", - "url" - ], - "properties": { - "description": { - "type": "string" - }, - "duration": { - "description": "Maybe client knows, or we process later", - "type": "integer" - }, - "format": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "url": { - "description": "The S3 Key or Full URL", - "type": "string" - } - } - }, - "video.UpdateVideoRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "ad_template_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "video.UploadURLRequest": { - "type": "object", - "required": [ - "content_type", - "filename", - "size" - ], - "properties": { - "content_type": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - } - }, - "securityDefinitions": { - "BearerAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } - } -}` - -// SwaggerInfo holds exported Swagger Info so clients can modify it -var SwaggerInfo = &swag.Spec{ - Version: "1.0", - Host: "localhost:8080", - BasePath: "/", - Schemes: []string{}, - Title: "Stream API", - Description: "This is the API server for Stream application.", - InfoInstanceName: "swagger", - SwaggerTemplate: docTemplate, - LeftDelim: "{{", - RightDelim: "}}", -} - -func init() { - swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) -} diff --git a/docs/swagger.json b/docs/swagger.json deleted file mode 100644 index dfc0795..0000000 --- a/docs/swagger.json +++ /dev/null @@ -1,4224 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "This is the API server for Stream application.", - "title": "Stream API", - "termsOfService": "http://swagger.io/terms/", - "contact": { - "name": "API Support", - "url": "http://www.swagger.io/support", - "email": "support@swagger.io" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version": "1.0" - }, - "host": "localhost:8080", - "basePath": "/", - "paths": { - "/ad-templates": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all VAST ad templates for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "List Ad Templates", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplateListPayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a VAST ad template for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Create Ad Template", - "parameters": [ - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/adtemplates.SaveAdTemplateRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplatePayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/ad-templates/{id}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a VAST ad template for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Update Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/adtemplates.SaveAdTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/adtemplates.TemplatePayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a VAST ad template for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "ad-templates" - ], - "summary": "Delete Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ad-templates": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all ad templates across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Ad Templates", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Search by name", - "name": "search", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create an ad template for any user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Ad Template", - "parameters": [ - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminAdTemplateRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ad-templates/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get ad template detail (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Ad Template Detail", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update an ad template for any user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Ad Template", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Ad template payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminAdTemplateRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete any ad template by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Ad Template (Admin)", - "parameters": [ - { - "type": "string", - "description": "Ad Template ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns currently connected render agents and current runtime stats", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "List connected render agents", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents/{id}/restart": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Sends a restart command to a currently connected render agent", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Restart connected render agent", - "parameters": [ - { - "type": "string", - "description": "Agent ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/agents/{id}/update": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Sends an update command to a currently connected render agent", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Update connected render agent", - "parameters": [ - { - "type": "string", - "description": "Agent ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/dashboard": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get system-wide statistics for the admin dashboard", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Admin Dashboard", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/admin.DashboardPayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns paginated render jobs for admin management", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "List render jobs", - "parameters": [ - { - "type": "integer", - "description": "Offset", - "name": "offset", - "in": "query" - }, - { - "type": "integer", - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Agent ID", - "name": "agent_id", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Queues a new render job for agents", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Create render job", - "parameters": [ - { - "description": "Job payload", - "name": "payload", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.createJobRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns a render job by ID", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Get render job detail", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/cancel": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Cancels a pending or running render job", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Cancel render job", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/logs": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Returns plain text logs for a render job", - "produces": [ - "text/plain" - ], - "tags": [ - "admin-render" - ], - "summary": "Get render job logs", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/jobs/{id}/retry": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Retries a failed or cancelled render job", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Retry render job", - "parameters": [ - { - "type": "string", - "description": "Job ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/payments": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all payments across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Payments", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by status", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a model subscription charge for a user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Payment", - "parameters": [ - { - "description": "Payment payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.CreateAdminPaymentRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/payments/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get payment detail (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Payment Detail", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update payment status safely without hard delete (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Payment", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Payment update payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateAdminPaymentRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/plans": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all plans with usage counts (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List Plans", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a plan (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Plan", - "parameters": [ - { - "description": "Plan payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SavePlanRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/plans/{id}": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a plan (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Plan", - "parameters": [ - { - "type": "string", - "description": "Plan ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Plan payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SavePlanRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a plan, or deactivate it if already used (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Plan", - "parameters": [ - { - "type": "string", - "description": "Plan ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List Users", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Search by email or username", - "name": "search", - "in": "query" - }, - { - "type": "string", - "description": "Filter by role", - "name": "role", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a user from admin panel (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create User", - "parameters": [ - { - "description": "User payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.CreateAdminUserRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get detailed info about a single user (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get User Detail", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update a user from admin panel (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update User", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "User payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateAdminUserRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a user and their data (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete User", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/users/{id}/role": { - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Change user role (admin only). Valid: USER, ADMIN, BLOCK", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update User Role", - "parameters": [ - { - "type": "string", - "description": "User ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Role payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.UpdateUserRoleRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/videos": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated list of all videos across users (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "List All Videos", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 20, - "description": "Limit", - "name": "limit", - "in": "query" - }, - { - "type": "string", - "description": "Search by title", - "name": "search", - "in": "query" - }, - { - "type": "string", - "description": "Filter by user ID", - "name": "user_id", - "in": "query" - }, - { - "type": "string", - "description": "Filter by status", - "name": "status", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a model video record for a user (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Create Video", - "parameters": [ - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminVideoRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/videos/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get video detail by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Get Video Detail", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update video metadata and status (admin only)", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Update Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/admin.SaveAdminVideoRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete any video by ID (admin only)", - "produces": [ - "application/json" - ], - "tags": [ - "admin" - ], - "summary": "Delete Video (Admin)", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/admin/ws": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Upgrade to websocket for authenticated admin realtime job and agent updates", - "produces": [ - "application/json" - ], - "tags": [ - "admin-render" - ], - "summary": "Admin realtime websocket", - "responses": { - "101": { - "description": "Switching Protocols", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "403": { - "description": "Forbidden", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/change-password": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Change the authenticated user's local password", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Change Password", - "parameters": [ - { - "description": "Password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ChangePasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/forgot-password": { - "post": { - "description": "Request password reset link", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Forgot Password", - "parameters": [ - { - "description": "Forgot password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ForgotPasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/google/callback": { - "get": { - "description": "Callback for Google Login", - "tags": [ - "auth" - ], - "summary": "Google Callback", - "responses": { - "307": { - "description": "Temporary Redirect" - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/google/login": { - "get": { - "description": "Redirect to Google for Login", - "tags": [ - "auth" - ], - "summary": "Google Login", - "responses": {} - } - }, - "/auth/login": { - "post": { - "description": "Login with email and password", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Login", - "parameters": [ - { - "description": "Login payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.LoginRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/auth.UserPayload" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/logout": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Logout user and clear cookies", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Logout", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/register": { - "post": { - "description": "Register a new user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Register", - "parameters": [ - { - "description": "Registration payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.RegisterRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/auth/reset-password": { - "post": { - "description": "Reset password using token", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Reset Password", - "parameters": [ - { - "description": "Reset password payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.ResetPasswordRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/domains": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all whitelisted domains for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "List Domains", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Add a domain to the current user's whitelist", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "Create Domain", - "parameters": [ - { - "description": "Domain payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/domains.CreateDomainRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/domains/{id}": { - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Remove a domain from the current user's whitelist", - "produces": [ - "application/json" - ], - "tags": [ - "domains" - ], - "summary": "Delete Domain", - "parameters": [ - { - "type": "string", - "description": "Domain ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/health/detailed": { - "get": { - "description": "Returns detailed health state for database, redis, and render dependencies", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Detailed health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/services.HealthReport" - } - } - } - } - }, - "/health/live": { - "get": { - "description": "Returns liveness status for the API and render module", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Liveness health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - "/health/ready": { - "get": { - "description": "Returns readiness status including render gRPC availability flag", - "produces": [ - "application/json" - ], - "tags": [ - "health" - ], - "summary": "Readiness health check", - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "503": { - "description": "Service Unavailable", - "schema": { - "type": "object", - "additionalProperties": true - } - } - } - } - }, - "/me": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get the authenticated user's profile payload", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Get Current User", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update the authenticated user's profile information", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Update Current User", - "parameters": [ - { - "description": "Profile payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/auth.UpdateMeRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Permanently delete the authenticated user's account and related data", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Delete My Account", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/me/clear-data": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Remove videos and settings-related resources for the authenticated user", - "produces": [ - "application/json" - ], - "tags": [ - "auth" - ], - "summary": "Clear My Data", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get notifications for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "List Notifications", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete all notifications for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Clear Notifications", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/read-all": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Mark all notifications as read for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Mark All Notifications Read", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/{id}": { - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a single notification for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Delete Notification", - "parameters": [ - { - "type": "string", - "description": "Notification ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/notifications/{id}/read": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Mark a single notification as read for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "notifications" - ], - "summary": "Mark Notification Read", - "parameters": [ - { - "type": "string", - "description": "Notification ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create a new payment for buying or renewing a plan", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "Create Payment", - "parameters": [ - { - "description": "Payment Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/payment.CreatePaymentRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments/history": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get payment history for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "List Payment History", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/payments/{id}/invoice": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Download invoice text for a payment or wallet top-up", - "produces": [ - "text/plain" - ], - "tags": [ - "payment" - ], - "summary": "Download Invoice", - "parameters": [ - { - "type": "string", - "description": "Payment ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "type": "string" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/plans": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get all active plans", - "produces": [ - "application/json" - ], - "tags": [ - "plan" - ], - "summary": "List Plans", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/settings/preferences": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get notification, player, and locale preferences for the current user", - "produces": [ - "application/json" - ], - "tags": [ - "settings" - ], - "summary": "Get Preferences", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update notification, player, and locale preferences for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "settings" - ], - "summary": "Update Preferences", - "parameters": [ - { - "description": "Preferences payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/preferences.SettingsPreferencesRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/usage": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get the authenticated user's total video count and total storage usage", - "produces": [ - "application/json" - ], - "tags": [ - "usage" - ], - "summary": "Get Usage", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/usage.UsagePayload" - } - } - } - ] - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get paginated videos", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "List Videos", - "parameters": [ - { - "type": "integer", - "default": 1, - "description": "Page number", - "name": "page", - "in": "query" - }, - { - "type": "integer", - "default": 10, - "description": "Page size", - "name": "limit", - "in": "query" - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Create video record after upload", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Create Video", - "parameters": [ - { - "description": "Video Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.CreateVideoRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Video" - } - } - } - ] - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos/upload-url": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Generate presigned URL for video upload", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Get Upload URL", - "parameters": [ - { - "description": "File Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.UploadURLRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/videos/{id}": { - "get": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Get video details by ID", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Get Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/response.Response" - }, - { - "type": "object", - "properties": { - "data": { - "$ref": "#/definitions/model.Video" - } - } - } - ] - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "put": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Update title and description for a video owned by the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Update Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - }, - { - "description": "Video payload", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/video.UpdateVideoRequest" - } - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - }, - "delete": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Delete a video owned by the current user", - "produces": [ - "application/json" - ], - "tags": [ - "video" - ], - "summary": "Delete Video", - "parameters": [ - { - "type": "string", - "description": "Video ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "404": { - "description": "Not Found", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - }, - "/wallet/topups": { - "post": { - "security": [ - { - "BearerAuth": [] - } - ], - "description": "Add funds to wallet balance for the current user", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "payment" - ], - "summary": "Top Up Wallet", - "parameters": [ - { - "description": "Topup Info", - "name": "request", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/payment.TopupWalletRequest" - } - } - ], - "responses": { - "201": { - "description": "Created", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "400": { - "description": "Bad Request", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "401": { - "description": "Unauthorized", - "schema": { - "$ref": "#/definitions/response.Response" - } - }, - "500": { - "description": "Internal Server Error", - "schema": { - "$ref": "#/definitions/response.Response" - } - } - } - } - } - }, - "definitions": { - "admin.CreateAdminPaymentRequest": { - "type": "object", - "required": [ - "payment_method", - "plan_id", - "term_months", - "user_id" - ], - "properties": { - "payment_method": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "term_months": { - "type": "integer" - }, - "topup_amount": { - "type": "number" - }, - "user_id": { - "type": "string" - } - } - }, - "admin.CreateAdminUserRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string", - "minLength": 6 - }, - "plan_id": { - "type": "string" - }, - "role": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "admin.DashboardPayload": { - "type": "object", - "properties": { - "active_subscriptions": { - "type": "integer" - }, - "new_users_today": { - "type": "integer" - }, - "new_videos_today": { - "type": "integer" - }, - "total_ad_templates": { - "type": "integer" - }, - "total_payments": { - "type": "integer" - }, - "total_revenue": { - "type": "number" - }, - "total_storage_used": { - "type": "integer" - }, - "total_users": { - "type": "integer" - }, - "total_videos": { - "type": "integer" - } - } - }, - "admin.SaveAdminAdTemplateRequest": { - "type": "object", - "required": [ - "name", - "user_id", - "vast_tag_url" - ], - "properties": { - "ad_format": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "admin.SaveAdminVideoRequest": { - "type": "object", - "required": [ - "size", - "title", - "url", - "user_id" - ], - "properties": { - "ad_template_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "format": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - }, - "user_id": { - "type": "string" - } - } - }, - "admin.SavePlanRequest": { - "type": "object", - "required": [ - "cycle", - "name", - "price", - "storage_limit", - "upload_limit" - ], - "properties": { - "cycle": { - "type": "string" - }, - "description": { - "type": "string" - }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, - "is_active": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "price": { - "type": "number" - }, - "storage_limit": { - "type": "integer" - }, - "upload_limit": { - "type": "integer" - } - } - }, - "admin.UpdateAdminPaymentRequest": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string" - } - } - }, - "admin.UpdateAdminUserRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "role": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "admin.UpdateUserRoleRequest": { - "type": "object", - "required": [ - "role" - ], - "properties": { - "role": { - "type": "string" - } - } - }, - "admin.createJobRequest": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "image": { - "type": "string" - }, - "name": { - "type": "string" - }, - "priority": { - "type": "integer" - }, - "time_limit": { - "type": "integer" - }, - "user_id": { - "type": "string" - } - } - }, - "adtemplates.SaveAdTemplateRequest": { - "type": "object", - "required": [ - "name", - "vast_tag_url" - ], - "properties": { - "ad_format": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "adtemplates.TemplateListPayload": { - "type": "object", - "properties": { - "templates": { - "type": "array", - "items": { - "$ref": "#/definitions/model.AdTemplate" - } - } - } - }, - "adtemplates.TemplatePayload": { - "type": "object", - "properties": { - "template": { - "$ref": "#/definitions/model.AdTemplate" - } - } - }, - "auth.ChangePasswordRequest": { - "type": "object", - "required": [ - "current_password", - "new_password" - ], - "properties": { - "current_password": { - "type": "string" - }, - "new_password": { - "type": "string", - "minLength": 6 - } - } - }, - "auth.ForgotPasswordRequest": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - } - } - }, - "auth.LoginRequest": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } - } - }, - "auth.RegisterRequest": { - "type": "object", - "required": [ - "email", - "password", - "username" - ], - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string", - "minLength": 6 - }, - "username": { - "type": "string" - } - } - }, - "auth.ResetPasswordRequest": { - "type": "object", - "required": [ - "new_password", - "token" - ], - "properties": { - "new_password": { - "type": "string", - "minLength": 6 - }, - "token": { - "type": "string" - } - } - }, - "auth.UpdateMeRequest": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "username": { - "type": "string" - } - } - }, - "auth.UserPayload": { - "type": "object", - "properties": { - "avatar": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "email": { - "type": "string" - }, - "google_id": { - "type": "string" - }, - "id": { - "type": "string" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "plan_expires_at": { - "type": "string" - }, - "plan_expiring_soon": { - "type": "boolean" - }, - "plan_id": { - "type": "string" - }, - "plan_payment_method": { - "type": "string" - }, - "plan_started_at": { - "type": "string" - }, - "plan_term_months": { - "type": "integer" - }, - "role": { - "type": "string" - }, - "storage_used": { - "type": "integer" - }, - "updated_at": { - "type": "string" - }, - "username": { - "type": "string" - }, - "wallet_balance": { - "type": "number" - } - } - }, - "domains.CreateDomainRequest": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - } - } - }, - "model.AdTemplate": { - "type": "object", - "properties": { - "ad_format": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "is_active": { - "type": "boolean" - }, - "is_default": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "vast_tag_url": { - "type": "string" - } - } - }, - "model.Video": { - "type": "object", - "properties": { - "created_at": { - "type": "string" - }, - "description": { - "type": "string" - }, - "duration": { - "type": "integer" - }, - "format": { - "type": "string" - }, - "hls_path": { - "type": "string" - }, - "hls_token": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "processing_status": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "status": { - "type": "string" - }, - "storage_type": { - "type": "string" - }, - "thumbnail": { - "type": "string" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "url": { - "type": "string" - }, - "user_id": { - "type": "string" - }, - "views": { - "type": "integer" - } - } - }, - "payment.CreatePaymentRequest": { - "type": "object", - "required": [ - "payment_method", - "plan_id", - "term_months" - ], - "properties": { - "payment_method": { - "type": "string" - }, - "plan_id": { - "type": "string" - }, - "term_months": { - "type": "integer" - }, - "topup_amount": { - "type": "number" - } - } - }, - "payment.TopupWalletRequest": { - "type": "object", - "required": [ - "amount" - ], - "properties": { - "amount": { - "type": "number" - } - } - }, - "preferences.SettingsPreferencesRequest": { - "type": "object", - "properties": { - "airplay": { - "type": "boolean" - }, - "autoplay": { - "type": "boolean" - }, - "chromecast": { - "type": "boolean" - }, - "email_notifications": { - "type": "boolean" - }, - "language": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "loop": { - "type": "boolean" - }, - "marketing_notifications": { - "type": "boolean" - }, - "muted": { - "type": "boolean" - }, - "pip": { - "type": "boolean" - }, - "push_notifications": { - "type": "boolean" - }, - "show_controls": { - "type": "boolean" - }, - "telegram_notifications": { - "type": "boolean" - } - } - }, - "response.Response": { - "type": "object", - "properties": { - "code": { - "type": "integer" - }, - "data": {}, - "message": { - "type": "string" - } - } - }, - "services.ComponentHealth": { - "type": "object", - "properties": { - "checked_at": { - "type": "string" - }, - "latency": { - "type": "string" - }, - "message": { - "type": "string" - }, - "status": { - "$ref": "#/definitions/services.HealthStatus" - } - } - }, - "services.HealthReport": { - "type": "object", - "properties": { - "checked_at": { - "type": "string" - }, - "components": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/services.ComponentHealth" - } - }, - "status": { - "$ref": "#/definitions/services.HealthStatus" - }, - "version": { - "type": "string" - } - } - }, - "services.HealthStatus": { - "type": "string", - "enum": [ - "healthy", - "unhealthy", - "degraded" - ], - "x-enum-varnames": [ - "HealthStatusHealthy", - "HealthStatusUnhealthy", - "HealthStatusDegraded" - ] - }, - "usage.UsagePayload": { - "type": "object", - "properties": { - "total_storage": { - "type": "integer" - }, - "total_videos": { - "type": "integer" - }, - "user_id": { - "type": "string" - } - } - }, - "video.CreateVideoRequest": { - "type": "object", - "required": [ - "size", - "title", - "url" - ], - "properties": { - "description": { - "type": "string" - }, - "duration": { - "description": "Maybe client knows, or we process later", - "type": "integer" - }, - "format": { - "type": "string" - }, - "size": { - "type": "integer" - }, - "title": { - "type": "string" - }, - "url": { - "description": "The S3 Key or Full URL", - "type": "string" - } - } - }, - "video.UpdateVideoRequest": { - "type": "object", - "required": [ - "title" - ], - "properties": { - "ad_template_id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - } - } - }, - "video.UploadURLRequest": { - "type": "object", - "required": [ - "content_type", - "filename", - "size" - ], - "properties": { - "content_type": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "size": { - "type": "integer" - } - } - } - }, - "securityDefinitions": { - "BearerAuth": { - "type": "apiKey", - "name": "Authorization", - "in": "header" - } - } -} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml deleted file mode 100644 index d4f8e9f..0000000 --- a/docs/swagger.yaml +++ /dev/null @@ -1,2690 +0,0 @@ -basePath: / -definitions: - admin.CreateAdminPaymentRequest: - properties: - payment_method: - type: string - plan_id: - type: string - term_months: - type: integer - topup_amount: - type: number - user_id: - type: string - required: - - payment_method - - plan_id - - term_months - - user_id - type: object - admin.CreateAdminUserRequest: - properties: - email: - type: string - password: - minLength: 6 - type: string - plan_id: - type: string - role: - type: string - username: - type: string - required: - - email - - password - type: object - admin.DashboardPayload: - properties: - active_subscriptions: - type: integer - new_users_today: - type: integer - new_videos_today: - type: integer - total_ad_templates: - type: integer - total_payments: - type: integer - total_revenue: - type: number - total_storage_used: - type: integer - total_users: - type: integer - total_videos: - type: integer - type: object - admin.SaveAdminAdTemplateRequest: - properties: - ad_format: - type: string - description: - type: string - duration: - type: integer - is_active: - type: boolean - is_default: - type: boolean - name: - type: string - user_id: - type: string - vast_tag_url: - type: string - required: - - name - - user_id - - vast_tag_url - type: object - admin.SaveAdminVideoRequest: - properties: - ad_template_id: - type: string - description: - type: string - duration: - type: integer - format: - type: string - size: - type: integer - status: - type: string - title: - type: string - url: - type: string - user_id: - type: string - required: - - size - - title - - url - - user_id - type: object - admin.SavePlanRequest: - properties: - cycle: - type: string - description: - type: string - features: - items: - type: string - type: array - is_active: - type: boolean - name: - type: string - price: - type: number - storage_limit: - type: integer - upload_limit: - type: integer - required: - - cycle - - name - - price - - storage_limit - - upload_limit - type: object - admin.UpdateAdminPaymentRequest: - properties: - status: - type: string - required: - - status - type: object - admin.UpdateAdminUserRequest: - properties: - email: - type: string - password: - type: string - plan_id: - type: string - role: - type: string - username: - type: string - type: object - admin.UpdateUserRoleRequest: - properties: - role: - type: string - required: - - role - type: object - admin.createJobRequest: - properties: - command: - type: string - env: - additionalProperties: - type: string - type: object - image: - type: string - name: - type: string - priority: - type: integer - time_limit: - type: integer - user_id: - type: string - type: object - adtemplates.SaveAdTemplateRequest: - properties: - ad_format: - type: string - description: - type: string - duration: - type: integer - is_active: - type: boolean - is_default: - type: boolean - name: - type: string - vast_tag_url: - type: string - required: - - name - - vast_tag_url - type: object - adtemplates.TemplateListPayload: - properties: - templates: - items: - $ref: '#/definitions/model.AdTemplate' - type: array - type: object - adtemplates.TemplatePayload: - properties: - template: - $ref: '#/definitions/model.AdTemplate' - type: object - auth.ChangePasswordRequest: - properties: - current_password: - type: string - new_password: - minLength: 6 - type: string - required: - - current_password - - new_password - type: object - auth.ForgotPasswordRequest: - properties: - email: - type: string - required: - - email - type: object - auth.LoginRequest: - properties: - email: - type: string - password: - type: string - required: - - email - - password - type: object - auth.RegisterRequest: - properties: - email: - type: string - password: - minLength: 6 - type: string - username: - type: string - required: - - email - - password - - username - type: object - auth.ResetPasswordRequest: - properties: - new_password: - minLength: 6 - type: string - token: - type: string - required: - - new_password - - token - type: object - auth.UpdateMeRequest: - properties: - email: - type: string - language: - type: string - locale: - type: string - username: - type: string - type: object - auth.UserPayload: - properties: - avatar: - type: string - created_at: - type: string - email: - type: string - google_id: - type: string - id: - type: string - language: - type: string - locale: - type: string - plan_expires_at: - type: string - plan_expiring_soon: - type: boolean - plan_id: - type: string - plan_payment_method: - type: string - plan_started_at: - type: string - plan_term_months: - type: integer - role: - type: string - storage_used: - type: integer - updated_at: - type: string - username: - type: string - wallet_balance: - type: number - type: object - domains.CreateDomainRequest: - properties: - name: - type: string - required: - - name - type: object - model.AdTemplate: - properties: - ad_format: - type: string - created_at: - type: string - description: - type: string - duration: - type: integer - id: - type: string - is_active: - type: boolean - is_default: - type: boolean - name: - type: string - updated_at: - type: string - user_id: - type: string - vast_tag_url: - type: string - type: object - model.Video: - properties: - created_at: - type: string - description: - type: string - duration: - type: integer - format: - type: string - hls_path: - type: string - hls_token: - type: string - id: - type: string - name: - type: string - processing_status: - type: string - size: - type: integer - status: - type: string - storage_type: - type: string - thumbnail: - type: string - title: - type: string - updated_at: - type: string - url: - type: string - user_id: - type: string - views: - type: integer - type: object - payment.CreatePaymentRequest: - properties: - payment_method: - type: string - plan_id: - type: string - term_months: - type: integer - topup_amount: - type: number - required: - - payment_method - - plan_id - - term_months - type: object - payment.TopupWalletRequest: - properties: - amount: - type: number - required: - - amount - type: object - preferences.SettingsPreferencesRequest: - properties: - airplay: - type: boolean - autoplay: - type: boolean - chromecast: - type: boolean - email_notifications: - type: boolean - language: - type: string - locale: - type: string - loop: - type: boolean - marketing_notifications: - type: boolean - muted: - type: boolean - pip: - type: boolean - push_notifications: - type: boolean - show_controls: - type: boolean - telegram_notifications: - type: boolean - type: object - response.Response: - properties: - code: - type: integer - data: {} - message: - type: string - type: object - services.ComponentHealth: - properties: - checked_at: - type: string - latency: - type: string - message: - type: string - status: - $ref: '#/definitions/services.HealthStatus' - type: object - services.HealthReport: - properties: - checked_at: - type: string - components: - additionalProperties: - $ref: '#/definitions/services.ComponentHealth' - type: object - status: - $ref: '#/definitions/services.HealthStatus' - version: - type: string - type: object - services.HealthStatus: - enum: - - healthy - - unhealthy - - degraded - type: string - x-enum-varnames: - - HealthStatusHealthy - - HealthStatusUnhealthy - - HealthStatusDegraded - usage.UsagePayload: - properties: - total_storage: - type: integer - total_videos: - type: integer - user_id: - type: string - type: object - video.CreateVideoRequest: - properties: - description: - type: string - duration: - description: Maybe client knows, or we process later - type: integer - format: - type: string - size: - type: integer - title: - type: string - url: - description: The S3 Key or Full URL - type: string - required: - - size - - title - - url - type: object - video.UpdateVideoRequest: - properties: - ad_template_id: - type: string - description: - type: string - title: - type: string - required: - - title - type: object - video.UploadURLRequest: - properties: - content_type: - type: string - filename: - type: string - size: - type: integer - required: - - content_type - - filename - - size - type: object -host: localhost:8080 -info: - contact: - email: support@swagger.io - name: API Support - url: http://www.swagger.io/support - description: This is the API server for Stream application. - license: - name: Apache 2.0 - url: http://www.apache.org/licenses/LICENSE-2.0.html - termsOfService: http://swagger.io/terms/ - title: Stream API - version: "1.0" -paths: - /ad-templates: - get: - description: Get all VAST ad templates for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/adtemplates.TemplateListPayload' - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Ad Templates - tags: - - ad-templates - post: - consumes: - - application/json - description: Create a VAST ad template for the current user - parameters: - - description: Ad template payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/adtemplates.SaveAdTemplateRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/adtemplates.TemplatePayload' - type: object - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Ad Template - tags: - - ad-templates - /ad-templates/{id}: - delete: - description: Delete a VAST ad template for the current user - parameters: - - description: Ad Template ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Ad Template - tags: - - ad-templates - put: - consumes: - - application/json - description: Update a VAST ad template for the current user - parameters: - - description: Ad Template ID - in: path - name: id - required: true - type: string - - description: Ad template payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/adtemplates.SaveAdTemplateRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/adtemplates.TemplatePayload' - type: object - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Ad Template - tags: - - ad-templates - /admin/ad-templates: - get: - description: Get paginated list of all ad templates across users (admin only) - parameters: - - default: 1 - description: Page - in: query - name: page - type: integer - - default: 20 - description: Limit - in: query - name: limit - type: integer - - description: Filter by user ID - in: query - name: user_id - type: string - - description: Search by name - in: query - name: search - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List All Ad Templates - tags: - - admin - post: - consumes: - - application/json - description: Create an ad template for any user (admin only) - parameters: - - description: Ad template payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SaveAdminAdTemplateRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Ad Template - tags: - - admin - /admin/ad-templates/{id}: - delete: - description: Delete any ad template by ID (admin only) - parameters: - - description: Ad Template ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Ad Template (Admin) - tags: - - admin - get: - description: Get ad template detail (admin only) - parameters: - - description: Ad Template ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Ad Template Detail - tags: - - admin - put: - consumes: - - application/json - description: Update an ad template for any user (admin only) - parameters: - - description: Ad Template ID - in: path - name: id - required: true - type: string - - description: Ad template payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SaveAdminAdTemplateRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Ad Template - tags: - - admin - /admin/agents: - get: - description: Returns currently connected render agents and current runtime stats - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List connected render agents - tags: - - admin-render - /admin/agents/{id}/restart: - post: - description: Sends a restart command to a currently connected render agent - parameters: - - description: Agent ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Restart connected render agent - tags: - - admin-render - /admin/agents/{id}/update: - post: - description: Sends an update command to a currently connected render agent - parameters: - - description: Agent ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "503": - description: Service Unavailable - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update connected render agent - tags: - - admin-render - /admin/dashboard: - get: - description: Get system-wide statistics for the admin dashboard - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/admin.DashboardPayload' - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Admin Dashboard - tags: - - admin - /admin/jobs: - get: - description: Returns paginated render jobs for admin management - parameters: - - description: Offset - in: query - name: offset - type: integer - - description: Limit - in: query - name: limit - type: integer - - description: Agent ID - in: query - name: agent_id - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List render jobs - tags: - - admin-render - post: - consumes: - - application/json - description: Queues a new render job for agents - parameters: - - description: Job payload - in: body - name: payload - required: true - schema: - $ref: '#/definitions/admin.createJobRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create render job - tags: - - admin-render - /admin/jobs/{id}: - get: - description: Returns a render job by ID - parameters: - - description: Job ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get render job detail - tags: - - admin-render - /admin/jobs/{id}/cancel: - post: - description: Cancels a pending or running render job - parameters: - - description: Job ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Cancel render job - tags: - - admin-render - /admin/jobs/{id}/logs: - get: - description: Returns plain text logs for a render job - parameters: - - description: Job ID - in: path - name: id - required: true - type: string - produces: - - text/plain - responses: - "200": - description: OK - schema: - type: string - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get render job logs - tags: - - admin-render - /admin/jobs/{id}/retry: - post: - description: Retries a failed or cancelled render job - parameters: - - description: Job ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Retry render job - tags: - - admin-render - /admin/payments: - get: - description: Get paginated list of all payments across users (admin only) - parameters: - - default: 1 - description: Page - in: query - name: page - type: integer - - default: 20 - description: Limit - in: query - name: limit - type: integer - - description: Filter by user ID - in: query - name: user_id - type: string - - description: Filter by status - in: query - name: status - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List All Payments - tags: - - admin - post: - consumes: - - application/json - description: Create a model subscription charge for a user (admin only) - parameters: - - description: Payment payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.CreateAdminPaymentRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Payment - tags: - - admin - /admin/payments/{id}: - get: - description: Get payment detail (admin only) - parameters: - - description: Payment ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Payment Detail - tags: - - admin - put: - consumes: - - application/json - description: Update payment status safely without hard delete (admin only) - parameters: - - description: Payment ID - in: path - name: id - required: true - type: string - - description: Payment update payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.UpdateAdminPaymentRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Payment - tags: - - admin - /admin/plans: - get: - description: Get all plans with usage counts (admin only) - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Plans - tags: - - admin - post: - consumes: - - application/json - description: Create a plan (admin only) - parameters: - - description: Plan payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SavePlanRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Plan - tags: - - admin - /admin/plans/{id}: - delete: - description: Delete a plan, or deactivate it if already used (admin only) - parameters: - - description: Plan ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Plan - tags: - - admin - put: - consumes: - - application/json - description: Update a plan (admin only) - parameters: - - description: Plan ID - in: path - name: id - required: true - type: string - - description: Plan payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SavePlanRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Plan - tags: - - admin - /admin/users: - get: - description: Get paginated list of all users (admin only) - parameters: - - default: 1 - description: Page - in: query - name: page - type: integer - - default: 20 - description: Limit - in: query - name: limit - type: integer - - description: Search by email or username - in: query - name: search - type: string - - description: Filter by role - in: query - name: role - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Users - tags: - - admin - post: - consumes: - - application/json - description: Create a user from admin panel (admin only) - parameters: - - description: User payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.CreateAdminUserRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create User - tags: - - admin - /admin/users/{id}: - delete: - description: Delete a user and their data (admin only) - parameters: - - description: User ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete User - tags: - - admin - get: - description: Get detailed info about a single user (admin only) - parameters: - - description: User ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get User Detail - tags: - - admin - put: - consumes: - - application/json - description: Update a user from admin panel (admin only) - parameters: - - description: User ID - in: path - name: id - required: true - type: string - - description: User payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.UpdateAdminUserRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update User - tags: - - admin - /admin/users/{id}/role: - put: - consumes: - - application/json - description: 'Change user role (admin only). Valid: USER, ADMIN, BLOCK' - parameters: - - description: User ID - in: path - name: id - required: true - type: string - - description: Role payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.UpdateUserRoleRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update User Role - tags: - - admin - /admin/videos: - get: - description: Get paginated list of all videos across users (admin only) - parameters: - - default: 1 - description: Page - in: query - name: page - type: integer - - default: 20 - description: Limit - in: query - name: limit - type: integer - - description: Search by title - in: query - name: search - type: string - - description: Filter by user ID - in: query - name: user_id - type: string - - description: Filter by status - in: query - name: status - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List All Videos - tags: - - admin - post: - consumes: - - application/json - description: Create a model video record for a user (admin only) - parameters: - - description: Video payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SaveAdminVideoRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Video - tags: - - admin - /admin/videos/{id}: - delete: - description: Delete any video by ID (admin only) - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Video (Admin) - tags: - - admin - get: - description: Get video detail by ID (admin only) - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Video Detail - tags: - - admin - put: - consumes: - - application/json - description: Update video metadata and status (admin only) - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - - description: Video payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/admin.SaveAdminVideoRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Video - tags: - - admin - /admin/ws: - get: - description: Upgrade to websocket for authenticated admin realtime job and agent - updates - produces: - - application/json - responses: - "101": - description: Switching Protocols - schema: - type: string - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "403": - description: Forbidden - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Admin realtime websocket - tags: - - admin-render - /auth/change-password: - post: - consumes: - - application/json - description: Change the authenticated user's local password - parameters: - - description: Password payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.ChangePasswordRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Change Password - tags: - - auth - /auth/forgot-password: - post: - consumes: - - application/json - description: Request password reset link - parameters: - - description: Forgot password payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.ForgotPasswordRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - summary: Forgot Password - tags: - - auth - /auth/google/callback: - get: - description: Callback for Google Login - responses: - "307": - description: Temporary Redirect - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - summary: Google Callback - tags: - - auth - /auth/google/login: - get: - description: Redirect to Google for Login - responses: {} - summary: Google Login - tags: - - auth - /auth/login: - post: - consumes: - - application/json - description: Login with email and password - parameters: - - description: Login payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.LoginRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/auth.UserPayload' - type: object - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - summary: Login - tags: - - auth - /auth/logout: - post: - description: Logout user and clear cookies - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Logout - tags: - - auth - /auth/register: - post: - consumes: - - application/json - description: Register a new user - parameters: - - description: Registration payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.RegisterRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - summary: Register - tags: - - auth - /auth/reset-password: - post: - consumes: - - application/json - description: Reset password using token - parameters: - - description: Reset password payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.ResetPasswordRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - summary: Reset Password - tags: - - auth - /domains: - get: - description: Get all whitelisted domains for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Domains - tags: - - domains - post: - consumes: - - application/json - description: Add a domain to the current user's whitelist - parameters: - - description: Domain payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/domains.CreateDomainRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Domain - tags: - - domains - /domains/{id}: - delete: - description: Remove a domain from the current user's whitelist - parameters: - - description: Domain ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Domain - tags: - - domains - /health/detailed: - get: - description: Returns detailed health state for database, redis, and render dependencies - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/services.HealthReport' - summary: Detailed health check - tags: - - health - /health/live: - get: - description: Returns liveness status for the API and render module - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: - type: string - type: object - "503": - description: Service Unavailable - schema: - additionalProperties: - type: string - type: object - summary: Liveness health check - tags: - - health - /health/ready: - get: - description: Returns readiness status including render gRPC availability flag - produces: - - application/json - responses: - "200": - description: OK - schema: - additionalProperties: true - type: object - "503": - description: Service Unavailable - schema: - additionalProperties: true - type: object - summary: Readiness health check - tags: - - health - /me: - delete: - description: Permanently delete the authenticated user's account and related - data - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete My Account - tags: - - auth - get: - description: Get the authenticated user's profile payload - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Current User - tags: - - auth - put: - consumes: - - application/json - description: Update the authenticated user's profile information - parameters: - - description: Profile payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/auth.UpdateMeRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Current User - tags: - - auth - /me/clear-data: - post: - description: Remove videos and settings-related resources for the authenticated - user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Clear My Data - tags: - - auth - /notifications: - delete: - description: Delete all notifications for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Clear Notifications - tags: - - notifications - get: - description: Get notifications for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Notifications - tags: - - notifications - /notifications/{id}: - delete: - description: Delete a single notification for the current user - parameters: - - description: Notification ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Notification - tags: - - notifications - /notifications/{id}/read: - post: - description: Mark a single notification as read for the current user - parameters: - - description: Notification ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Mark Notification Read - tags: - - notifications - /notifications/read-all: - post: - description: Mark all notifications as read for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Mark All Notifications Read - tags: - - notifications - /payments: - post: - consumes: - - application/json - description: Create a new payment for buying or renewing a plan - parameters: - - description: Payment Info - in: body - name: request - required: true - schema: - $ref: '#/definitions/payment.CreatePaymentRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Payment - tags: - - payment - /payments/{id}/invoice: - get: - description: Download invoice text for a payment or wallet top-up - parameters: - - description: Payment ID - in: path - name: id - required: true - type: string - produces: - - text/plain - responses: - "200": - description: OK - schema: - type: string - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Download Invoice - tags: - - payment - /payments/history: - get: - description: Get payment history for the current user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Payment History - tags: - - payment - /plans: - get: - description: Get all active plans - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Plans - tags: - - plan - /settings/preferences: - get: - description: Get notification, player, and locale preferences for the current - user - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Preferences - tags: - - settings - put: - consumes: - - application/json - description: Update notification, player, and locale preferences for the current - user - parameters: - - description: Preferences payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/preferences.SettingsPreferencesRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Preferences - tags: - - settings - /usage: - get: - description: Get the authenticated user's total video count and total storage - usage - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/usage.UsagePayload' - type: object - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Usage - tags: - - usage - /videos: - get: - description: Get paginated videos - parameters: - - default: 1 - description: Page number - in: query - name: page - type: integer - - default: 10 - description: Page size - in: query - name: limit - type: integer - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: List Videos - tags: - - video - post: - consumes: - - application/json - description: Create video record after upload - parameters: - - description: Video Info - in: body - name: request - required: true - schema: - $ref: '#/definitions/video.CreateVideoRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/model.Video' - type: object - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Create Video - tags: - - video - /videos/{id}: - delete: - description: Delete a video owned by the current user - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Delete Video - tags: - - video - get: - description: Get video details by ID - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/response.Response' - - properties: - data: - $ref: '#/definitions/model.Video' - type: object - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Video - tags: - - video - put: - consumes: - - application/json - description: Update title and description for a video owned by the current user - parameters: - - description: Video ID - in: path - name: id - required: true - type: string - - description: Video payload - in: body - name: request - required: true - schema: - $ref: '#/definitions/video.UpdateVideoRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "404": - description: Not Found - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Update Video - tags: - - video - /videos/upload-url: - post: - consumes: - - application/json - description: Generate presigned URL for video upload - parameters: - - description: File Info - in: body - name: request - required: true - schema: - $ref: '#/definitions/video.UploadURLRequest' - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Get Upload URL - tags: - - video - /wallet/topups: - post: - consumes: - - application/json - description: Add funds to wallet balance for the current user - parameters: - - description: Topup Info - in: body - name: request - required: true - schema: - $ref: '#/definitions/payment.TopupWalletRequest' - produces: - - application/json - responses: - "201": - description: Created - schema: - $ref: '#/definitions/response.Response' - "400": - description: Bad Request - schema: - $ref: '#/definitions/response.Response' - "401": - description: Unauthorized - schema: - $ref: '#/definitions/response.Response' - "500": - description: Internal Server Error - schema: - $ref: '#/definitions/response.Response' - security: - - BearerAuth: [] - summary: Top Up Wallet - tags: - - payment -securityDefinitions: - BearerAuth: - in: header - name: Authorization - type: apiKey -swagger: "2.0" diff --git a/go.mod b/go.mod index e74206b..7c7070b 100644 --- a/go.mod +++ b/go.mod @@ -63,6 +63,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sony/gobreaker v1.0.0 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/go.sum b/go.sum index 9294013..2481972 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ= +github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= diff --git a/internal/adapters/circuitbreaker/circuit_breaker.go b/internal/adapters/circuitbreaker/circuit_breaker.go new file mode 100644 index 0000000..f2ac99b --- /dev/null +++ b/internal/adapters/circuitbreaker/circuit_breaker.go @@ -0,0 +1,66 @@ +package circuitbreaker + +import ( + "context" + "errors" + "time" + + "github.com/sony/gobreaker" +) + +var ( + ErrCircuitOpen = errors.New("circuit breaker is open") +) + +type CircuitBreaker struct { + cb *gobreaker.CircuitBreaker +} + +// New creates a new circuit breaker with default settings +func New(name string) *CircuitBreaker { + settings := gobreaker.Settings{ + Name: name, + MaxRequests: 3, + Interval: time.Second * 60, + Timeout: time.Second * 30, + ReadyToTrip: func(counts gobreaker.Counts) bool { + failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) + return counts.Requests >= 3 && failureRatio >= 0.6 + }, + OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) { + // Log state changes + // logger.Info("Circuit breaker state changed", "name", name, "from", from, "to", to) + }, + } + + return &CircuitBreaker{ + cb: gobreaker.NewCircuitBreaker(settings), + } +} + +// Execute runs the function with circuit breaker protection +func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error { + _, err := cb.cb.Execute(func() (interface{}, error) { + return nil, fn() + }) + + if err == gobreaker.ErrOpenState { + return ErrCircuitOpen + } + + return err +} + +// ExecuteWithFallback runs the function with circuit breaker and fallback +func (cb *CircuitBreaker) ExecuteWithFallback(ctx context.Context, fn func() error, fallback func() error) error { + err := cb.Execute(ctx, fn) + if err == ErrCircuitOpen && fallback != nil { + return fallback() + } + return err +} + +// State returns the current state of the circuit breaker +func (cb *CircuitBreaker) State() gobreaker.State { + return cb.cb.State() +} diff --git a/internal/adapters/redis/dlq.go b/internal/adapters/redis/dlq.go index fb2c8a6..6008b81 100644 --- a/internal/adapters/redis/dlq.go +++ b/internal/adapters/redis/dlq.go @@ -1,5 +1,6 @@ package redis +// DeadLetterQueue provides a simple implementation of a dead letter queue using Redis sorted sets and hashes. Each failed job is stored as a JSON-encoded entry with metadata such as failure time, reason, and retry count. The sorted set allows for efficient retrieval of jobs in order of failure time, while the hash stores the detailed metadata for each job. import ( "context" "encoding/json" diff --git a/internal/repository/account_repository.go b/internal/repository/account_repository.go new file mode 100644 index 0000000..571343d --- /dev/null +++ b/internal/repository/account_repository.go @@ -0,0 +1,73 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type accountRepository struct { + db *gorm.DB +} + +func NewAccountRepository(db *gorm.DB) *accountRepository { + return &accountRepository{db: db} +} + +func (r *accountRepository) DeleteUserAccount(ctx context.Context, userID string) error { + userID = strings.TrimSpace(userID) + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { + return err + } + if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil { + return err + } + return nil + }) +} + +func (r *accountRepository) ClearUserData(ctx context.Context, userID string) error { + userID = strings.TrimSpace(userID) + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { + return err + } + if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { + return err + } + if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{"storage_used": 0}).Error; err != nil { + return err + } + return nil + }) +} diff --git a/internal/repository/ad_template_repository.go b/internal/repository/ad_template_repository.go new file mode 100644 index 0000000..e90ce5a --- /dev/null +++ b/internal/repository/ad_template_repository.go @@ -0,0 +1,152 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type adTemplateRepository struct { + db *gorm.DB +} + +func NewAdTemplateRepository(db *gorm.DB) *adTemplateRepository { + return &adTemplateRepository{db: db} +} + +func (r *adTemplateRepository) ListByUser(ctx context.Context, userID string) ([]model.AdTemplate, error) { + var items []model.AdTemplate + err := r.db.WithContext(ctx). + Where("user_id = ?", strings.TrimSpace(userID)). + Order("is_default DESC"). + Order("created_at DESC"). + Find(&items).Error + return items, err +} + +func (r *adTemplateRepository) ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.AdTemplate, int64, error) { + db := r.db.WithContext(ctx).Model(&model.AdTemplate{}) + if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" { + like := "%" + trimmedSearch + "%" + db = db.Where("name ILIKE ?", like) + } + if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" { + db = db.Where("user_id = ?", trimmedUserID) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var templates []model.AdTemplate + if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&templates).Error; err != nil { + return nil, 0, err + } + return templates, total, nil +} + +func (r *adTemplateRepository) CountAll(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.AdTemplate{}).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *adTemplateRepository) GetByID(ctx context.Context, id string) (*model.AdTemplate, error) { + var item model.AdTemplate + if err := r.db.WithContext(ctx). + Where("id = ?", strings.TrimSpace(id)). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *adTemplateRepository) GetByIDAndUser(ctx context.Context, id string, userID string) (*model.AdTemplate, error) { + var item model.AdTemplate + if err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *adTemplateRepository) ExistsByIDAndUser(ctx context.Context, id string, userID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.AdTemplate{}). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Count(&count).Error + return count > 0, err +} + +func (r *adTemplateRepository) CreateWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if item.IsDefault { + if err := tx.Model(&model.AdTemplate{}). + Where("user_id = ?", strings.TrimSpace(userID)). + Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Create(item).Error + }) +} + +func (r *adTemplateRepository) SaveWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if item.IsDefault { + if err := tx.Model(&model.AdTemplate{}). + Where("user_id = ? AND id <> ?", strings.TrimSpace(userID), item.ID). + Update("is_default", false).Error; err != nil { + return err + } + } + return tx.Save(item).Error + }) +} + +func (r *adTemplateRepository) DeleteByIDAndUserAndClearVideos(ctx context.Context, id string, userID string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Video{}). + Where("user_id = ? AND ad_id = ?", strings.TrimSpace(userID), strings.TrimSpace(id)). + Update("ad_id", nil).Error; err != nil { + return err + } + + res := tx.Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&model.AdTemplate{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (r *adTemplateRepository) DeleteByIDAndClearVideos(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.Video{}). + Where("ad_id = ?", strings.TrimSpace(id)). + Update("ad_id", nil).Error; err != nil { + return err + } + + res := tx.Where("id = ?", strings.TrimSpace(id)). + Delete(&model.AdTemplate{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} diff --git a/internal/repository/billing_repository.go b/internal/repository/billing_repository.go new file mode 100644 index 0000000..1f0cced --- /dev/null +++ b/internal/repository/billing_repository.go @@ -0,0 +1,41 @@ +package repository + +import ( + "context" + "time" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type billingRepository struct { + db *gorm.DB +} + +func NewBillingRepository(db *gorm.DB) *billingRepository { + return &billingRepository{db: db} +} + +func (r *billingRepository) GetWalletBalance(ctx context.Context, userID string) (float64, error) { + return model.GetWalletBalance(ctx, r.db, userID) +} + +func (r *billingRepository) GetWalletBalanceTx(tx *gorm.DB, ctx context.Context, userID string) (float64, error) { + return model.GetWalletBalance(ctx, tx, userID) +} + +func (r *billingRepository) GetLatestPlanSubscription(ctx context.Context, userID string) (*model.PlanSubscription, error) { + return model.GetLatestPlanSubscription(ctx, r.db, userID) +} + +func (r *billingRepository) GetLatestPlanSubscriptionTx(tx *gorm.DB, ctx context.Context, userID string) (*model.PlanSubscription, error) { + return model.GetLatestPlanSubscription(ctx, tx, userID) +} + +func (r *billingRepository) CountActiveSubscriptions(ctx context.Context, now time.Time) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("expires_at > ?", now).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/repository/domain_repository.go b/internal/repository/domain_repository.go new file mode 100644 index 0000000..891be4f --- /dev/null +++ b/internal/repository/domain_repository.go @@ -0,0 +1,46 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type domainRepository struct { + db *gorm.DB +} + +func NewDomainRepository(db *gorm.DB) *domainRepository { + return &domainRepository{db: db} +} + +func (r *domainRepository) ListByUser(ctx context.Context, userID string) ([]model.Domain, error) { + var rows []model.Domain + err := r.db.WithContext(ctx). + Where("user_id = ?", strings.TrimSpace(userID)). + Order("created_at DESC"). + Find(&rows).Error + return rows, err +} + +func (r *domainRepository) CountByUserAndName(ctx context.Context, userID string, name string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx). + Model(&model.Domain{}). + Where("user_id = ? AND name = ?", strings.TrimSpace(userID), strings.TrimSpace(name)). + Count(&count).Error + return count, err +} + +func (r *domainRepository) Create(ctx context.Context, item *model.Domain) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *domainRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) { + res := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&model.Domain{}) + return res.RowsAffected, res.Error +} diff --git a/internal/repository/job_repository.go b/internal/repository/job_repository.go new file mode 100644 index 0000000..88208db --- /dev/null +++ b/internal/repository/job_repository.go @@ -0,0 +1,102 @@ +package repository + +import ( + "context" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "stream.api/internal/database/model" + "stream.api/internal/database/query" +) + +type jobRepository struct { + db *gorm.DB +} + +func NewJobRepository(db *gorm.DB) *jobRepository { + return &jobRepository{db: db} +} + +func (r *jobRepository) ListByOffset(ctx context.Context, agentID string, offset int, limit int) ([]*model.Job, int64, error) { + q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) + if trimmedAgentID := strings.TrimSpace(agentID); trimmedAgentID != "" { + agentNumeric, err := strconv.ParseInt(trimmedAgentID, 10, 64) + if err != nil { + return []*model.Job{}, 0, nil + } + q = q.Where(query.Job.AgentID.Eq(agentNumeric)) + } + + jobs, total, err := q.FindByPage(offset, limit) + if err != nil { + return nil, 0, err + } + items := make([]*model.Job, 0, len(jobs)) + for _, job := range jobs { + items = append(items, job) + } + return items, total, nil +} + +func (r *jobRepository) ListByCursor(ctx context.Context, agentID string, cursorTime time.Time, cursorID string, limit int) ([]*model.Job, bool, error) { + q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) + if trimmedAgentID := strings.TrimSpace(agentID); trimmedAgentID != "" { + agentNumeric, err := strconv.ParseInt(trimmedAgentID, 10, 64) + if err != nil { + return []*model.Job{}, false, nil + } + q = q.Where(query.Job.AgentID.Eq(agentNumeric)) + } + + queryDB := q.UnderlyingDB() + if !cursorTime.IsZero() && strings.TrimSpace(cursorID) != "" { + queryDB = queryDB.Where("(created_at < ?) OR (created_at = ? AND id < ?)", cursorTime, cursorTime, strings.TrimSpace(cursorID)) + } + + var jobs []*model.Job + if err := queryDB.Limit(limit + 1).Find(&jobs).Error; err != nil { + return nil, false, err + } + + hasMore := len(jobs) > limit + if hasMore { + jobs = jobs[:limit] + } + return jobs, hasMore, nil +} + +func (r *jobRepository) Create(ctx context.Context, job *model.Job) error { + return query.Job.WithContext(ctx).Create(job) +} + +func (r *jobRepository) GetByID(ctx context.Context, id string) (*model.Job, error) { + return query.Job.WithContext(ctx).Where(query.Job.ID.Eq(strings.TrimSpace(id))).First() +} + +func (r *jobRepository) Save(ctx context.Context, job *model.Job) error { + return query.Job.WithContext(ctx).Save(job) +} + +func (r *jobRepository) UpdateVideoStatus(ctx context.Context, videoID string, statusValue string, processingStatus string) error { + videoID = strings.TrimSpace(videoID) + if videoID == "" { + return nil + } + _, err := query.Video.WithContext(ctx). + Where(query.Video.ID.Eq(videoID)). + Updates(map[string]any{"status": statusValue, "processing_status": processingStatus}) + return err +} + +func (r *jobRepository) GetLatestByVideoID(ctx context.Context, videoID string) (*model.Job, error) { + var job model.Job + if err := r.db.WithContext(ctx). + Where("config::jsonb ->> 'video_id' = ?", strings.TrimSpace(videoID)). + Order("created_at DESC"). + First(&job).Error; err != nil { + return nil, err + } + return &job, nil +} diff --git a/internal/repository/notification_repository.go b/internal/repository/notification_repository.go new file mode 100644 index 0000000..9a282d7 --- /dev/null +++ b/internal/repository/notification_repository.go @@ -0,0 +1,54 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type notificationRepository struct { + db *gorm.DB +} + +func NewNotificationRepository(db *gorm.DB) *notificationRepository { + return ¬ificationRepository{db: db} +} + +func (r *notificationRepository) ListByUser(ctx context.Context, userID string) ([]model.Notification, error) { + var rows []model.Notification + err := r.db.WithContext(ctx). + Where("user_id = ?", strings.TrimSpace(userID)). + Order("created_at DESC"). + Find(&rows).Error + return rows, err +} + +func (r *notificationRepository) MarkReadByIDAndUser(ctx context.Context, id string, userID string) (int64, error) { + res := r.db.WithContext(ctx). + Model(&model.Notification{}). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Update("is_read", true) + return res.RowsAffected, res.Error +} + +func (r *notificationRepository) MarkAllReadByUser(ctx context.Context, userID string) error { + return r.db.WithContext(ctx). + Model(&model.Notification{}). + Where("user_id = ? AND is_read = ?", strings.TrimSpace(userID), false). + Update("is_read", true).Error +} + +func (r *notificationRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) { + res := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&model.Notification{}) + return res.RowsAffected, res.Error +} + +func (r *notificationRepository) DeleteAllByUser(ctx context.Context, userID string) error { + return r.db.WithContext(ctx). + Where("user_id = ?", strings.TrimSpace(userID)). + Delete(&model.Notification{}).Error +} diff --git a/internal/repository/payment_repository.go b/internal/repository/payment_repository.go new file mode 100644 index 0000000..4c38d50 --- /dev/null +++ b/internal/repository/payment_repository.go @@ -0,0 +1,469 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "stream.api/internal/database/model" + "stream.api/internal/database/query" +) + +type PaymentHistoryRow struct { + ID string `gorm:"column:id"` + Amount float64 `gorm:"column:amount"` + Currency *string `gorm:"column:currency"` + Status *string `gorm:"column:status"` + PlanID *string `gorm:"column:plan_id"` + PlanName *string `gorm:"column:plan_name"` + InvoiceID string `gorm:"column:invoice_id"` + Kind string `gorm:"column:kind"` + TermMonths *int32 `gorm:"column:term_months"` + PaymentMethod *string `gorm:"column:payment_method"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + CreatedAt *time.Time `gorm:"column:created_at"` +} + +type paymentRepository struct { + db *gorm.DB +} + +func NewPaymentRepository(db *gorm.DB) *paymentRepository { + return &paymentRepository{db: db} +} + +func (r *paymentRepository) ListHistoryByUser(ctx context.Context, userID string, subscriptionKind string, walletTopupKind string, topupType string, limit int32, offset int) ([]PaymentHistoryRow, int64, error) { + baseQuery := ` + WITH history AS ( + SELECT + p.id AS id, + p.amount AS amount, + p.currency AS currency, + p.status AS status, + p.plan_id AS plan_id, + pl.name AS plan_name, + p.id AS invoice_id, + ? AS kind, + ps.term_months AS term_months, + ps.payment_method AS payment_method, + ps.expires_at AS expires_at, + p.created_at AS created_at + FROM payment AS p + LEFT JOIN plan AS pl ON pl.id = p.plan_id + LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id + WHERE p.user_id = ? + UNION ALL + SELECT + wt.id AS id, + wt.amount AS amount, + wt.currency AS currency, + 'SUCCESS' AS status, + NULL AS plan_id, + NULL AS plan_name, + wt.id AS invoice_id, + ? AS kind, + NULL AS term_months, + NULL AS payment_method, + NULL AS expires_at, + wt.created_at AS created_at + FROM wallet_transactions AS wt + WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL + ) + ` + + var total int64 + if err := r.db.WithContext(ctx). + Raw(baseQuery+`SELECT COUNT(*) FROM history`, subscriptionKind, userID, walletTopupKind, userID, topupType). + Scan(&total).Error; err != nil { + return nil, 0, err + } + + var rows []PaymentHistoryRow + if err := r.db.WithContext(ctx). + Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, subscriptionKind, userID, walletTopupKind, userID, topupType, limit, offset). + Scan(&rows).Error; err != nil { + return nil, 0, err + } + + return rows, total, nil +} + +func (r *paymentRepository) ListForAdmin(ctx context.Context, userID string, status string, limit int32, offset int) ([]model.Payment, int64, error) { + db := r.db.WithContext(ctx).Model(&model.Payment{}) + if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" { + db = db.Where("user_id = ?", trimmedUserID) + } + if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" { + db = db.Where("UPPER(status) = ?", strings.ToUpper(trimmedStatus)) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var payments []model.Payment + if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&payments).Error; err != nil { + return nil, 0, err + } + return payments, total, nil +} + +func (r *paymentRepository) CountAll(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.Payment{}).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *paymentRepository) SumSuccessfulAmount(ctx context.Context) (float64, error) { + var total float64 + if err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil +} + +func (r *paymentRepository) GetByID(ctx context.Context, paymentID string) (*model.Payment, error) { + return query.Payment.WithContext(ctx). + Where(query.Payment.ID.Eq(strings.TrimSpace(paymentID))). + First() +} + +func (r *paymentRepository) GetByIDAndUser(ctx context.Context, paymentID string, userID string) (*model.Payment, error) { + return query.Payment.WithContext(ctx). + Where(query.Payment.ID.Eq(strings.TrimSpace(paymentID)), query.Payment.UserID.Eq(strings.TrimSpace(userID))). + First() +} + +func (r *paymentRepository) GetStandaloneTopupByIDAndUser(ctx context.Context, id string, userID string, topupType string) (*model.WalletTransaction, error) { + var topup model.WalletTransaction + if err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", strings.TrimSpace(id), strings.TrimSpace(userID), topupType). + First(&topup).Error; err != nil { + return nil, err + } + return &topup, nil +} + +func (r *paymentRepository) GetSubscriptionByPaymentID(ctx context.Context, paymentID string) (*model.PlanSubscription, error) { + var subscription model.PlanSubscription + if err := r.db.WithContext(ctx). + Where("payment_id = ?", strings.TrimSpace(paymentID)). + Order("created_at DESC"). + First(&subscription).Error; err != nil { + return nil, err + } + return &subscription, nil +} + +func (r *paymentRepository) CountByPlanID(ctx context.Context, planID string) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *paymentRepository) CreatePayment(ctx context.Context, payment *model.Payment) error { + return r.db.WithContext(ctx).Create(payment).Error +} + +func (r *paymentRepository) Save(ctx context.Context, payment *model.Payment) error { + return r.db.WithContext(ctx).Save(payment).Error +} + +func (r *paymentRepository) CreatePaymentTx(tx *gorm.DB, ctx context.Context, payment *model.Payment) error { + return tx.Create(payment).Error +} + +func (r *paymentRepository) CreateWalletTransactionTx(tx *gorm.DB, ctx context.Context, txRecord *model.WalletTransaction) error { + return tx.Create(txRecord).Error +} + +func (r *paymentRepository) CreatePlanSubscriptionTx(tx *gorm.DB, ctx context.Context, subscription *model.PlanSubscription) error { + return tx.Create(subscription).Error +} + +func (r *paymentRepository) UpdateUserPlanID(ctx context.Context, userID string, planID string) error { + return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("plan_id", planID).Error +} + +func (r *paymentRepository) UpdateUserPlanIDTx(tx *gorm.DB, ctx context.Context, userID string, planID string) error { + return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Update("plan_id", strings.TrimSpace(planID)).Error +} + +func (r *paymentRepository) CreateNotificationTx(tx *gorm.DB, ctx context.Context, notification *model.Notification) error { + return tx.Create(notification).Error +} + +func (r *paymentRepository) CreateWalletTopupAndNotification(ctx context.Context, userID string, transaction *model.WalletTransaction, notification *model.Notification) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if _, err := r.lockUserForUpdateTx(tx, ctx, userID); err != nil { + return err + } + if err := r.CreateWalletTransactionTx(tx, ctx, transaction); err != nil { + return err + } + return r.CreateNotificationTx(tx, ctx, notification) + }) +} + +func (r *paymentRepository) ExecuteSubscriptionPayment(ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, invoiceID string, now time.Time, validateFunding func(currentWalletBalance float64) (float64, error)) (*model.PlanSubscription, float64, error) { + var ( + subscription *model.PlanSubscription + walletBalance float64 + ) + + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + referee, err := r.lockUserForUpdateTx(tx, ctx, userID) + if err != nil { + return err + } + + newExpiry, err := r.loadPaymentExpiryTx(tx, ctx, userID, termMonths, now) + if err != nil { + return err + } + currentWalletBalance, err := model.GetWalletBalance(ctx, tx, userID) + if err != nil { + return err + } + validatedTopupAmount, err := validateFunding(currentWalletBalance) + if err != nil { + return err + } + if err := r.CreatePaymentTx(tx, ctx, paymentRecord); err != nil { + return err + } + if err := r.createPaymentWalletTransactionsTx(tx, ctx, userID, plan, termMonths, paymentMethod, paymentRecord, paymentRecord.Amount, validatedTopupAmount, model.StringValue(paymentRecord.Currency)); err != nil { + return err + } + + subscription = &model.PlanSubscription{ + ID: uuid.New().String(), + UserID: userID, + PaymentID: paymentRecord.ID, + PlanID: plan.ID, + TermMonths: termMonths, + PaymentMethod: paymentMethod, + WalletAmount: paymentRecord.Amount, + TopupAmount: validatedTopupAmount, + StartedAt: now, + ExpiresAt: newExpiry, + } + if err := r.CreatePlanSubscriptionTx(tx, ctx, subscription); err != nil { + return err + } + if err := r.UpdateUserPlanIDTx(tx, ctx, userID, plan.ID); err != nil { + return err + } + + notification := &model.Notification{ + ID: uuid.New().String(), + UserID: userID, + Type: "billing.subscription", + Title: "Subscription activated", + Message: fmt.Sprintf("Your subscription to %s is active until %s.", plan.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")), + Metadata: model.StringPtr(mustMarshalJSON(map[string]any{ + "payment_id": paymentRecord.ID, + "invoice_id": invoiceID, + "plan_id": plan.ID, + "term_months": subscription.TermMonths, + "payment_method": subscription.PaymentMethod, + "wallet_amount": subscription.WalletAmount, + "topup_amount": subscription.TopupAmount, + "plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339), + })), + } + if err := r.CreateNotificationTx(tx, ctx, notification); err != nil { + return err + } + if err := r.maybeGrantReferralRewardTx(tx, ctx, referee, plan, paymentRecord, subscription); err != nil { + return err + } + walletBalance, err = model.GetWalletBalance(ctx, tx, userID) + return err + }) + + return subscription, walletBalance, err +} + +func (r *paymentRepository) lockUserForUpdateTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) { + trimmedUserID := strings.TrimSpace(userID) + if tx.Dialector.Name() == "sqlite" { + res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, gorm.ErrRecordNotFound + } + } + + var user model.User + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", trimmedUserID). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *paymentRepository) loadPaymentExpiryTx(tx *gorm.DB, ctx context.Context, userID string, termMonths int32, now time.Time) (time.Time, error) { + currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return time.Time{}, err + } + baseExpiry := now + if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) { + baseExpiry = currentSubscription.ExpiresAt.UTC() + } + return baseExpiry.AddDate(0, int(termMonths), 0), nil +} + +func (r *paymentRepository) createPaymentWalletTransactionsTx(tx *gorm.DB, ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error { + if paymentMethod == "topup" { + topupTransaction := &model.WalletTransaction{ + ID: uuid.New().String(), + UserID: userID, + Type: "topup", + Amount: topupAmount, + Currency: model.StringPtr(currency), + Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", plan.Name, termMonths)), + PaymentID: &paymentRecord.ID, + PlanID: &plan.ID, + TermMonths: &termMonths, + } + if err := r.CreateWalletTransactionTx(tx, ctx, topupTransaction); err != nil { + return err + } + } + + debitTransaction := &model.WalletTransaction{ + ID: uuid.New().String(), + UserID: userID, + Type: "subscription_debit", + Amount: -totalAmount, + Currency: model.StringPtr(currency), + Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", plan.Name, termMonths)), + PaymentID: &paymentRecord.ID, + PlanID: &plan.ID, + TermMonths: &termMonths, + } + return r.CreateWalletTransactionTx(tx, ctx, debitTransaction) +} + +func (r *paymentRepository) maybeGrantReferralRewardTx(tx *gorm.DB, ctx context.Context, referee *model.User, plan *model.Plan, paymentRecord *model.Payment, subscription *model.PlanSubscription) error { + if paymentRecord == nil || subscription == nil || plan == nil || referee == nil { + return nil + } + if subscription.PaymentMethod != "wallet" && subscription.PaymentMethod != "topup" { + return nil + } + if referee.ReferredByUserID == nil || strings.TrimSpace(*referee.ReferredByUserID) == "" { + return nil + } + if referee.ReferralRewardGrantedAt != nil || (referee.ReferralRewardPaymentID != nil && strings.TrimSpace(*referee.ReferralRewardPaymentID) != "") { + return nil + } + + var subscriptionCount int64 + if err := tx.WithContext(ctx).Model(&model.PlanSubscription{}).Where("user_id = ?", referee.ID).Count(&subscriptionCount).Error; err != nil { + return err + } + if subscriptionCount != 1 { + return nil + } + + referrer, err := r.lockUserForUpdateTx(tx, ctx, strings.TrimSpace(*referee.ReferredByUserID)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if referrer.ID == referee.ID || (referrer.ReferralEligible != nil && !*referrer.ReferralEligible) { + return nil + } + + bps := int32(500) + if referrer.ReferralRewardBps != nil { + bps = *referrer.ReferralRewardBps + if bps < 0 { + bps = 0 + } + if bps > 10000 { + bps = 10000 + } + } + if bps <= 0 { + return nil + } + + baseAmount := plan.Price * float64(subscription.TermMonths) + if baseAmount <= 0 { + return nil + } + rewardAmount := baseAmount * float64(bps) / 10000 + if rewardAmount <= 0 { + return nil + } + + refereeLabel := strings.TrimSpace(referee.Email) + if username := strings.TrimSpace(model.StringValue(referee.Username)); username != "" { + refereeLabel = "@" + username + } + + rewardTransaction := &model.WalletTransaction{ + ID: uuid.New().String(), + UserID: referrer.ID, + Type: "referral_reward", + Amount: rewardAmount, + Currency: paymentRecord.Currency, + Note: model.StringPtr(fmt.Sprintf("Referral reward for %s first subscription", referee.Email)), + PaymentID: &paymentRecord.ID, + PlanID: &plan.ID, + } + if err := tx.Create(rewardTransaction).Error; err != nil { + return err + } + notification := &model.Notification{ + ID: uuid.New().String(), + UserID: referrer.ID, + Type: "billing.referral_reward", + Title: "Referral reward granted", + Message: fmt.Sprintf("You received %.2f USD from %s's first subscription.", rewardAmount, refereeLabel), + Metadata: model.StringPtr(mustMarshalJSON(map[string]any{ + "payment_id": paymentRecord.ID, + "referee_id": referee.ID, + "amount": rewardAmount, + })), + } + if err := tx.Create(notification).Error; err != nil { + return err + } + + now := time.Now().UTC() + return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", referee.ID).Updates(map[string]any{ + "referral_reward_granted_at": now, + "referral_reward_payment_id": paymentRecord.ID, + "referral_reward_amount": rewardAmount, + }).Error +} + +func mustMarshalJSON(value any) string { + encoded, err := json.Marshal(value) + if err != nil { + return "{}" + } + return string(encoded) +} diff --git a/internal/repository/plan_repository.go b/internal/repository/plan_repository.go new file mode 100644 index 0000000..b2c9792 --- /dev/null +++ b/internal/repository/plan_repository.go @@ -0,0 +1,69 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type planRepository struct { + db *gorm.DB +} + +func NewPlanRepository(db *gorm.DB) *planRepository { + return &planRepository{db: db} +} + +func (r *planRepository) GetByID(ctx context.Context, planID string) (*model.Plan, error) { + var plan model.Plan + if err := r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(planID)).First(&plan).Error; err != nil { + return nil, err + } + return &plan, nil +} + +func (r *planRepository) ListActive(ctx context.Context) ([]model.Plan, error) { + var plans []model.Plan + if err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil { + return nil, err + } + return plans, nil +} + +func (r *planRepository) ListAll(ctx context.Context) ([]model.Plan, error) { + var plans []model.Plan + if err := r.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil { + return nil, err + } + return plans, nil +} + +func (r *planRepository) Create(ctx context.Context, plan *model.Plan) error { + return r.db.WithContext(ctx).Create(plan).Error +} + +func (r *planRepository) Save(ctx context.Context, plan *model.Plan) error { + return r.db.WithContext(ctx).Save(plan).Error +} + +func (r *planRepository) CountPaymentsByPlan(ctx context.Context, planID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error + return count, err +} + +func (r *planRepository) CountSubscriptionsByPlan(ctx context.Context, planID string) (int64, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error + return count, err +} + +func (r *planRepository) SetActive(ctx context.Context, planID string, isActive bool) error { + return r.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", strings.TrimSpace(planID)).Update("is_active", isActive).Error +} + +func (r *planRepository) DeleteByID(ctx context.Context, planID string) error { + return r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(planID)).Delete(&model.Plan{}).Error +} diff --git a/internal/repository/player_config_repository.go b/internal/repository/player_config_repository.go new file mode 100644 index 0000000..28daeab --- /dev/null +++ b/internal/repository/player_config_repository.go @@ -0,0 +1,277 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "stream.api/internal/database/model" +) + +type playerConfigRepository struct { + db *gorm.DB +} + +func NewPlayerConfigRepository(db *gorm.DB) *playerConfigRepository { + return &playerConfigRepository{db: db} +} + +func (r *playerConfigRepository) ListByUser(ctx context.Context, userID string) ([]model.PlayerConfig, error) { + var items []model.PlayerConfig + if err := r.db.WithContext(ctx). + Where("user_id = ?", strings.TrimSpace(userID)). + Order("is_default DESC"). + Order("created_at DESC"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *playerConfigRepository) ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.PlayerConfig, int64, error) { + db := r.db.WithContext(ctx).Model(&model.PlayerConfig{}) + if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" { + like := "%" + trimmedSearch + "%" + db = db.Where("name ILIKE ?", like) + } + if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" { + db = db.Where("user_id = ?", trimmedUserID) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var items []model.PlayerConfig + if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *playerConfigRepository) CountByUser(ctx context.Context, userID string) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&model.PlayerConfig{}). + Where("user_id = ?", strings.TrimSpace(userID)). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *playerConfigRepository) CountByUserTx(tx *gorm.DB, ctx context.Context, userID string) (int64, error) { + var count int64 + if err := tx.WithContext(ctx). + Model(&model.PlayerConfig{}). + Where("user_id = ?", strings.TrimSpace(userID)). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *playerConfigRepository) Create(ctx context.Context, item *model.PlayerConfig) error { + return r.db.WithContext(ctx).Create(item).Error +} + +func (r *playerConfigRepository) CreateWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if item.IsDefault { + if err := r.UnsetDefaultForUserTx(tx, userID, ""); err != nil { + return err + } + } + return tx.Create(item).Error + }) +} + +func (r *playerConfigRepository) CreateTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error { + return tx.Create(item).Error +} + +func (r *playerConfigRepository) GetByIDAndUser(ctx context.Context, id string, userID string) (*model.PlayerConfig, error) { + var item model.PlayerConfig + if err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *playerConfigRepository) GetByID(ctx context.Context, id string) (*model.PlayerConfig, error) { + var item model.PlayerConfig + if err := r.db.WithContext(ctx). + Where("id = ?", strings.TrimSpace(id)). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *playerConfigRepository) GetByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (*model.PlayerConfig, error) { + var item model.PlayerConfig + if err := tx.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func (r *playerConfigRepository) Save(ctx context.Context, item *model.PlayerConfig) error { + return r.db.WithContext(ctx).Save(item).Error +} + +func (r *playerConfigRepository) SaveWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if item.IsDefault { + if err := r.UnsetDefaultForUserTx(tx, userID, item.ID); err != nil { + return err + } + } + return tx.Save(item).Error + }) +} + +func (r *playerConfigRepository) SaveTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error { + return tx.Save(item).Error +} + +func (r *playerConfigRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) { + res := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&model.PlayerConfig{}) + return res.RowsAffected, res.Error +} + +func (r *playerConfigRepository) DeleteByID(ctx context.Context, id string) (int64, error) { + res := r.db.WithContext(ctx). + Where("id = ?", strings.TrimSpace(id)). + Delete(&model.PlayerConfig{}) + return res.RowsAffected, res.Error +} + +func (r *playerConfigRepository) DeleteByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (int64, error) { + res := tx.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)). + Delete(&model.PlayerConfig{}) + return res.RowsAffected, res.Error +} + +func (r *playerConfigRepository) UnsetDefaultForUser(ctx context.Context, userID string, excludeID string) error { + updates := map[string]any{"is_default": false} + db := r.db.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", strings.TrimSpace(userID)) + if strings.TrimSpace(excludeID) != "" { + db = db.Where("id != ?", strings.TrimSpace(excludeID)) + } + return db.Updates(updates).Error +} + +func (r *playerConfigRepository) UnsetDefaultForUserTx(tx *gorm.DB, userID string, excludeID string) error { + updates := map[string]any{"is_default": false} + db := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", strings.TrimSpace(userID)) + if strings.TrimSpace(excludeID) != "" { + db = db.Where("id != ?", strings.TrimSpace(excludeID)) + } + return db.Updates(updates).Error +} + +func (r *playerConfigRepository) CreateManaged(ctx context.Context, userID string, item *model.PlayerConfig, validate func(*model.User, int64) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID) + if err != nil { + return err + } + configCount, err := r.CountByUserTx(tx, ctx, userID) + if err != nil { + return err + } + if err := validate(lockedUser, configCount); err != nil { + return err + } + if item.IsDefault { + if err := r.UnsetDefaultForUserTx(tx, userID, ""); err != nil { + return err + } + } + return r.CreateTx(tx, ctx, item) + }) +} + +func (r *playerConfigRepository) UpdateManaged(ctx context.Context, userID string, id string, mutateAndValidate func(*model.PlayerConfig, *model.User, int64) error) (*model.PlayerConfig, error) { + var item *model.PlayerConfig + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID) + if err != nil { + return err + } + configCount, err := r.CountByUserTx(tx, ctx, userID) + if err != nil { + return err + } + item, err = r.GetByIDAndUserTx(tx, ctx, id, userID) + if err != nil { + return err + } + if err := mutateAndValidate(item, lockedUser, configCount); err != nil { + return err + } + if item.IsDefault { + if err := r.UnsetDefaultForUserTx(tx, userID, item.ID); err != nil { + return err + } + } + return r.SaveTx(tx, ctx, item) + }) + return item, err +} + +func (r *playerConfigRepository) DeleteManaged(ctx context.Context, userID string, id string, validate func(*model.User, int64) error) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID) + if err != nil { + return err + } + configCount, err := r.CountByUserTx(tx, ctx, userID) + if err != nil { + return err + } + if err := validate(lockedUser, configCount); err != nil { + return err + } + rowsAffected, err := r.DeleteByIDAndUserTx(tx, ctx, id, userID) + if err != nil { + return err + } + if rowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (r *playerConfigRepository) lockUserForUpdateTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) { + trimmedUserID := strings.TrimSpace(userID) + if tx.Dialector.Name() == "sqlite" { + res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, gorm.ErrRecordNotFound + } + } + + var user model.User + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", trimmedUserID). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/repository/user_preference_repository.go b/internal/repository/user_preference_repository.go new file mode 100644 index 0000000..6553066 --- /dev/null +++ b/internal/repository/user_preference_repository.go @@ -0,0 +1,24 @@ +package repository + +import ( + "context" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type userPreferenceRepository struct { + db *gorm.DB +} + +func NewUserPreferenceRepository(db *gorm.DB) *userPreferenceRepository { + return &userPreferenceRepository{db: db} +} + +func (r *userPreferenceRepository) FindOrCreateByUserID(ctx context.Context, userID string) (*model.UserPreference, error) { + return model.FindOrCreateUserPreference(ctx, r.db, userID) +} + +func (r *userPreferenceRepository) Save(ctx context.Context, pref *model.UserPreference) error { + return r.db.WithContext(ctx).Save(pref).Error +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go new file mode 100644 index 0000000..c399a9e --- /dev/null +++ b/internal/repository/user_repository.go @@ -0,0 +1,173 @@ +package repository + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "stream.api/internal/database/model" + "stream.api/internal/database/query" +) + +type userRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *userRepository { + return &userRepository{db: db} +} + +func (r *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) { + u := query.User + return u.WithContext(ctx).Where(u.Email.Eq(strings.TrimSpace(email))).First() +} + +func (r *userRepository) CountByEmail(ctx context.Context, email string) (int64, error) { + u := query.User + return u.WithContext(ctx).Where(u.Email.Eq(strings.TrimSpace(email))).Count() +} + +func (r *userRepository) GetByID(ctx context.Context, userID string) (*model.User, error) { + u := query.User + return u.WithContext(ctx).Where(u.ID.Eq(strings.TrimSpace(userID))).First() +} + +func (r *userRepository) ListForAdmin(ctx context.Context, search string, role string, limit int32, offset int) ([]model.User, int64, error) { + db := r.db.WithContext(ctx).Model(&model.User{}) + if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" { + like := "%" + trimmedSearch + "%" + db = db.Where("email ILIKE ? OR username ILIKE ?", like, like) + } + if trimmedRole := strings.TrimSpace(role); trimmedRole != "" { + db = db.Where("UPPER(role) = ?", strings.ToUpper(trimmedRole)) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var users []model.User + if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +func (r *userRepository) CountAll(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *userRepository) CountCreatedSince(ctx context.Context, since time.Time) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", since).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *userRepository) SumStorageUsed(ctx context.Context) (int64, error) { + var total int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Scan(&total).Error; err != nil { + return 0, err + } + return total, nil +} + +func (r *userRepository) GetEmailByID(ctx context.Context, userID string) (*string, error) { + var user model.User + if err := r.db.WithContext(ctx).Select("id, email").Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil { + return nil, err + } + return &user.Email, nil +} + +func (r *userRepository) GetReferralSummaryByID(ctx context.Context, userID string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx).Select("id, email, username").Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *userRepository) CountByPlanID(ctx context.Context, planID string) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *userRepository) LockByIDTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) { + trimmedUserID := strings.TrimSpace(userID) + if tx.Dialector.Name() == "sqlite" { + res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID) + if res.Error != nil { + return nil, res.Error + } + if res.RowsAffected == 0 { + return nil, gorm.ErrRecordNotFound + } + } + + var user model.User + if err := tx.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", trimmedUserID). + First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *userRepository) Create(ctx context.Context, user *model.User) error { + return query.User.WithContext(ctx).Create(user) +} + +func (r *userRepository) UpdateFieldsByID(ctx context.Context, userID string, updates map[string]any) error { + return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Updates(updates).Error +} + +func (r *userRepository) UpdateFieldsByIDTx(tx *gorm.DB, ctx context.Context, userID string, updates map[string]any) error { + return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Updates(updates).Error +} + +func (r *userRepository) UpdatePassword(ctx context.Context, userID string, passwordHash string) error { + _, err := query.User.WithContext(ctx). + Where(query.User.ID.Eq(strings.TrimSpace(userID))). + Update(query.User.Password, passwordHash) + return err +} + +func (r *userRepository) FindByReferralUsername(ctx context.Context, username string, limit int) ([]model.User, error) { + trimmed := strings.TrimSpace(username) + if trimmed == "" { + return nil, nil + } + var users []model.User + if err := r.db.WithContext(ctx). + Where("LOWER(username) = LOWER(?)", trimmed). + Order("created_at ASC, id ASC"). + Limit(limit). + Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} + +func (r *userRepository) CountSubscriptionsByUser(ctx context.Context, userID string) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&model.PlanSubscription{}). + Where("user_id = ?", strings.TrimSpace(userID)). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} diff --git a/internal/repository/video_repository.go b/internal/repository/video_repository.go new file mode 100644 index 0000000..b7ddf8f --- /dev/null +++ b/internal/repository/video_repository.go @@ -0,0 +1,210 @@ +package repository + +import ( + "context" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type videoRepository struct { + db *gorm.DB +} + +func NewVideoRepository(db *gorm.DB) *videoRepository { + return &videoRepository{db: db} +} + +func (r *videoRepository) ListByUser(ctx context.Context, userID string, search string, status string, offset int, limit int) ([]model.Video, int64, error) { + db := r.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", strings.TrimSpace(userID)) + if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" { + like := "%" + trimmedSearch + "%" + db = db.Where("title ILIKE ? OR description ILIKE ?", like, like) + } + if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" && !strings.EqualFold(trimmedStatus, "all") { + db = db.Where("status = ?", trimmedStatus) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var videos []model.Video + if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil { + return nil, 0, err + } + + return videos, total, nil +} + +func (r *videoRepository) ListForAdmin(ctx context.Context, search string, userID string, status string, offset int, limit int) ([]model.Video, int64, error) { + db := r.db.WithContext(ctx).Model(&model.Video{}) + if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" { + like := "%" + trimmedSearch + "%" + db = db.Where("title ILIKE ?", like) + } + if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" { + db = db.Where("user_id = ?", trimmedUserID) + } + if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" && !strings.EqualFold(trimmedStatus, "all") { + db = db.Where("status = ?", trimmedStatus) + } + + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + var videos []model.Video + if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil { + return nil, 0, err + } + return videos, total, nil +} + +func (r *videoRepository) CountAll(ctx context.Context) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.Video{}).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *videoRepository) CountCreatedSince(ctx context.Context, since time.Time) (int64, error) { + var count int64 + if err := r.db.WithContext(ctx).Model(&model.Video{}).Where("created_at >= ?", since).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +func (r *videoRepository) IncrementViews(ctx context.Context, videoID string, userID string) error { + return r.db.WithContext(ctx). + Model(&model.Video{}). + Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)). + UpdateColumn("views", gorm.Expr("views + ?", 1)).Error +} + +func (r *videoRepository) GetByIDAndUser(ctx context.Context, videoID string, userID string) (*model.Video, error) { + var video model.Video + if err := r.db.WithContext(ctx). + Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)). + First(&video).Error; err != nil { + return nil, err + } + return &video, nil +} + +func (r *videoRepository) GetByID(ctx context.Context, videoID string) (*model.Video, error) { + var video model.Video + if err := r.db.WithContext(ctx). + Where("id = ?", strings.TrimSpace(videoID)). + First(&video).Error; err != nil { + return nil, err + } + return &video, nil +} + +func (r *videoRepository) UpdateByIDAndUser(ctx context.Context, videoID string, userID string, updates map[string]any) (int64, error) { + res := r.db.WithContext(ctx). + Model(&model.Video{}). + Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)). + Updates(updates) + return res.RowsAffected, res.Error +} + +func (r *videoRepository) CountByUser(ctx context.Context, userID string) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", strings.TrimSpace(userID)).Count(&total).Error + return total, err +} + +func (r *videoRepository) DeleteByIDAndUserWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return r.deleteByIDAndUserWithStorageUpdateTx(tx, ctx, videoID, userID, videoSize) + }) +} + +func (r *videoRepository) deleteByIDAndUserWithStorageUpdateTx(tx *gorm.DB, ctx context.Context, videoID string, userID string, videoSize int64) error { + if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)).Delete(&model.Video{}).Error; err != nil { + return err + } + return tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(userID)). + UpdateColumn("storage_used", gorm.Expr("storage_used - ?", videoSize)).Error +} + +func (r *videoRepository) DeleteByIDWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Where("id = ?", strings.TrimSpace(videoID)).Delete(&model.Video{}).Error; err != nil { + return err + } + return tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(userID)). + UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", videoSize)).Error + }) +} + +func (r *videoRepository) UpdateAdminVideo(ctx context.Context, video *model.Video, oldUserID string, oldSize int64, adTemplateID *string) error { + if video == nil { + return gorm.ErrInvalidData + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Save(video).Error; err != nil { + return err + } + + if strings.TrimSpace(oldUserID) == strings.TrimSpace(video.UserID) { + delta := video.Size - oldSize + if delta != 0 { + if err := tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(video.UserID)). + UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil { + return err + } + } + } else { + if err := tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(oldUserID)). + UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil { + return err + } + if err := tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(video.UserID)). + UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil { + return err + } + } + + if adTemplateID == nil { + return nil + } + + trimmedAdID := strings.TrimSpace(*adTemplateID) + if trimmedAdID == "" { + if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil { + return err + } + video.AdID = nil + return nil + } + + var template model.AdTemplate + if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmedAdID, strings.TrimSpace(video.UserID)).First(&template).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return gorm.ErrRecordNotFound + } + return err + } + if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil { + return err + } + video.AdID = &template.ID + return nil + }) +} diff --git a/internal/repository/video_workflow_repository.go b/internal/repository/video_workflow_repository.go new file mode 100644 index 0000000..62a8179 --- /dev/null +++ b/internal/repository/video_workflow_repository.go @@ -0,0 +1,67 @@ +package repository + +import ( + "context" + "strings" + + "gorm.io/gorm" + "stream.api/internal/database/model" +) + +type videoWorkflowRepository struct { + db *gorm.DB +} + +func NewVideoWorkflowRepository(db *gorm.DB) *videoWorkflowRepository { + return &videoWorkflowRepository{db: db} +} + +func (r *videoWorkflowRepository) GetUserByID(ctx context.Context, userID string) (*model.User, error) { + var user model.User + if err := r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *videoWorkflowRepository) CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(video).Error; err != nil { + return err + } + if err := tx.Model(&model.User{}). + Where("id = ?", strings.TrimSpace(userID)). + UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil { + return err + } + if video == nil || adTemplateID == nil { + return nil + } + + trimmed := strings.TrimSpace(*adTemplateID) + if trimmed == "" { + if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil { + return err + } + video.AdID = nil + return nil + } + + var template model.AdTemplate + if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, strings.TrimSpace(userID)).First(&template).Error; err != nil { + return err + } + if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil { + return err + } + video.AdID = &template.ID + return nil + }) +} + +func (r *videoWorkflowRepository) MarkVideoJobFailed(ctx context.Context, videoID string) error { + return r.db.WithContext(ctx). + Model(&model.Video{}). + Where("id = ?", strings.TrimSpace(videoID)). + Updates(map[string]any{"status": "failed", "processing_status": "FAILED"}).Error +} diff --git a/internal/service/__test__/service_admin_jobs_agents_test.go b/internal/service/__test__/service_admin_jobs_agents_test.go index ade9e33..5cef158 100644 --- a/internal/service/__test__/service_admin_jobs_agents_test.go +++ b/internal/service/__test__/service_admin_jobs_agents_test.go @@ -10,7 +10,7 @@ import ( appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" runtimeservices "stream.api/internal/service/runtime/services" - "stream.api/internal/service/video" + renderworkflow "stream.api/internal/workflow/render" ) func TestListAdminJobsCursorPagination(t *testing.T) { @@ -18,7 +18,7 @@ func TestListAdminJobsCursorPagination(t *testing.T) { ensureTestJobsTable(t, db) services := newTestAppServices(t, db) - services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) + services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) baseTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) @@ -67,7 +67,7 @@ func TestListAdminJobsInvalidCursor(t *testing.T) { ensureTestJobsTable(t, db) services := newTestAppServices(t, db) - services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) + services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) conn, cleanup := newTestGRPCServer(t, services) @@ -86,7 +86,7 @@ func TestListAdminJobsCursorRejectsAgentMismatch(t *testing.T) { ensureTestJobsTable(t, db) services := newTestAppServices(t, db) - services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) + services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil)) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) baseTime := time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC) diff --git a/internal/service/admin_helpers.go b/internal/service/admin_helpers.go index ff13de1..037f056 100644 --- a/internal/service/admin_helpers.go +++ b/internal/service/admin_helpers.go @@ -36,42 +36,12 @@ func (s *appServices) ensurePlanExists(ctx context.Context, planID *string) erro if trimmed == "" { return nil } - var count int64 - if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { + if _, err := s.planRepository.GetByID(ctx, trimmed); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return status.Error(codes.InvalidArgument, "Plan not found") + } return status.Error(codes.Internal, "Failed to validate plan") } - if count == 0 { - return status.Error(codes.InvalidArgument, "Plan not found") - } - return nil -} - -func (s *appServices) saveAdminVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error { - if video == nil || adTemplateID == nil { - return nil - } - - trimmed := strings.TrimSpace(*adTemplateID) - if trimmed == "" { - if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil { - return err - } - video.AdID = nil - return nil - } - - var template model.AdTemplate - if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("Ad template not found") - } - return err - } - - if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil { - return err - } - video.AdID = &template.ID return nil } @@ -181,7 +151,7 @@ func (s *appServices) buildAdminUser(ctx context.Context, user *model.User) (*ap } payload.VideoCount = videoCount - walletBalance, err := model.GetWalletBalance(ctx, s.db, user.ID) + walletBalance, err := s.billingRepository.GetWalletBalance(ctx, user.ID) if err != nil { return nil, err } @@ -338,30 +308,26 @@ func (s *appServices) buildAdminPayment(ctx context.Context, payment *model.Paym } func (s *appServices) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { - var videoCount int64 - if err := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil { - return 0, err - } - return videoCount, nil + return s.videoRepository.CountByUser(ctx, userID) } func (s *appServices) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) { - var user model.User - if err := s.db.WithContext(ctx).Select("id, email").Where("id = ?", userID).First(&user).Error; err != nil { + email, err := s.userRepository.GetEmailByID(ctx, userID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } - return nullableTrimmedString(&user.Email), nil + return nullableTrimmedString(email), nil } func (s *appServices) loadReferralUserSummary(ctx context.Context, userID string) (*appv1.ReferralUserSummary, error) { if strings.TrimSpace(userID) == "" { return nil, nil } - var user model.User - if err := s.db.WithContext(ctx).Select("id, email, username").Where("id = ?", userID).First(&user).Error; err != nil { + user, err := s.userRepository.GetReferralSummaryByID(ctx, userID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } @@ -378,8 +344,8 @@ func (s *appServices) loadAdminPlanName(ctx context.Context, planID *string) (*s if planID == nil || strings.TrimSpace(*planID) == "" { return nil, nil } - var plan model.Plan - if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { + plan, err := s.planRepository.GetByID(ctx, *planID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } @@ -404,8 +370,8 @@ func (s *appServices) loadAdminVideoAdTemplateDetails(ctx context.Context, video } func (s *appServices) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) { - var template model.AdTemplate - if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", adTemplateID).First(&template).Error; err != nil { + template, err := s.adTemplateRepository.GetByID(ctx, adTemplateID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } @@ -420,11 +386,8 @@ func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string) return nil, nil } - var job model.Job - if err := s.db.WithContext(ctx). - Where("config::jsonb ->> 'video_id' = ?", videoID). - Order("created_at DESC"). - First(&job).Error; err != nil { + job, err := s.jobRepository.GetLatestByVideoID(ctx, videoID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } @@ -434,8 +397,8 @@ func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string) } func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) { - var subscription model.PlanSubscription - if err := s.db.WithContext(ctx).Where("payment_id = ?", paymentID).Order("created_at DESC").First(&subscription).Error; err != nil { + subscription, err := s.paymentRepository.GetSubscriptionByPaymentID(ctx, paymentID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, nil, nil, nil } @@ -450,18 +413,18 @@ func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, p } func (s *appServices) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) { - var userCount int64 - if err := s.db.WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil { + userCount, err := s.userRepository.CountByPlanID(ctx, planID) + if err != nil { return 0, 0, 0, err } - var paymentCount int64 - if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil { + paymentCount, err := s.paymentRepository.CountByPlanID(ctx, planID) + if err != nil { return 0, 0, 0, err } - var subscriptionCount int64 - if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil { + subscriptionCount, err := s.planRepository.CountSubscriptionsByPlan(ctx, planID) + if err != nil { return 0, 0, 0, err } @@ -511,22 +474,6 @@ func validateAdminPlayerConfigInput(userID, name string) string { return "" } -func (s *appServices) unsetAdminDefaultTemplates(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { - query := tx.WithContext(ctx).Model(&model.AdTemplate{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} - -func (s *appServices) unsetAdminDefaultPlayerConfigs(ctx context.Context, tx *gorm.DB, userID, excludeID string) error { - query := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} - func (s *appServices) buildAdminPlan(ctx context.Context, plan *model.Plan) (*appv1.AdminPlan, error) { if plan == nil { return nil, nil diff --git a/internal/service/catalog_mapper.go b/internal/service/catalog_mapper.go new file mode 100644 index 0000000..1f7f3ce --- /dev/null +++ b/internal/service/catalog_mapper.go @@ -0,0 +1,103 @@ +package service + +import appv1 "stream.api/internal/api/proto/app/v1" +import "stream.api/internal/database/model" + +func toProtoDomain(item *model.Domain) *appv1.Domain { + if item == nil { + return nil + } + return &appv1.Domain{ + Id: item.ID, + Name: item.Name, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(item.UpdatedAt), + } +} + +func toProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate { + if item == nil { + return nil + } + return &appv1.AdTemplate{ + Id: item.ID, + Name: item.Name, + Description: item.Description, + VastTagUrl: item.VastTagURL, + AdFormat: model.StringValue(item.AdFormat), + Duration: int64PtrToInt32Ptr(item.Duration), + IsActive: boolValue(item.IsActive), + IsDefault: item.IsDefault, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(item.UpdatedAt), + } +} + +func toProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig { + if item == nil { + return nil + } + return &appv1.PlayerConfig{ + Id: item.ID, + Name: item.Name, + Description: item.Description, + Autoplay: item.Autoplay, + Loop: item.Loop, + Muted: item.Muted, + ShowControls: boolValue(item.ShowControls), + Pip: boolValue(item.Pip), + Airplay: boolValue(item.Airplay), + Chromecast: boolValue(item.Chromecast), + IsActive: boolValue(item.IsActive), + IsDefault: item.IsDefault, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(&item.UpdatedAt), + EncrytionM3U8: boolValue(item.EncrytionM3u8), + LogoUrl: nullableTrimmedString(item.LogoURL), + } +} + +func toProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig { + if item == nil { + return nil + } + return &appv1.AdminPlayerConfig{ + Id: item.ID, + UserId: item.UserID, + Name: item.Name, + Description: item.Description, + Autoplay: item.Autoplay, + Loop: item.Loop, + Muted: item.Muted, + ShowControls: boolValue(item.ShowControls), + Pip: boolValue(item.Pip), + Airplay: boolValue(item.Airplay), + Chromecast: boolValue(item.Chromecast), + IsActive: boolValue(item.IsActive), + IsDefault: item.IsDefault, + OwnerEmail: ownerEmail, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(&item.UpdatedAt), + EncrytionM3U8: boolValue(item.EncrytionM3u8), + LogoUrl: nullableTrimmedString(item.LogoURL), + } +} + +func toProtoPlan(item *model.Plan) *appv1.Plan { + if item == nil { + return nil + } + return &appv1.Plan{ + Id: item.ID, + Name: item.Name, + Description: item.Description, + Price: item.Price, + Cycle: item.Cycle, + StorageLimit: item.StorageLimit, + UploadLimit: item.UploadLimit, + DurationLimit: item.DurationLimit, + QualityLimit: item.QualityLimit, + Features: item.Features, + IsActive: boolValue(item.IsActive), + } +} diff --git a/internal/service/domain_helpers.go b/internal/service/domain_helpers.go new file mode 100644 index 0000000..996bc3f --- /dev/null +++ b/internal/service/domain_helpers.go @@ -0,0 +1,29 @@ +package service + +import "strings" + +func normalizeDomain(value string) string { + normalized := strings.TrimSpace(strings.ToLower(value)) + normalized = strings.TrimPrefix(normalized, "https://") + normalized = strings.TrimPrefix(normalized, "http://") + normalized = strings.TrimPrefix(normalized, "www.") + normalized = strings.TrimSuffix(normalized, "/") + return normalized +} + +func normalizeAdFormat(value string) string { + switch strings.TrimSpace(strings.ToLower(value)) { + case "mid-roll", "post-roll": + return strings.TrimSpace(strings.ToLower(value)) + default: + return "pre-roll" + } +} + +func adTemplateIsActive(value *bool) bool { + return value == nil || *value +} + +func playerConfigIsActive(value *bool) bool { + return value == nil || *value +} diff --git a/internal/service/interface.go b/internal/service/interface.go new file mode 100644 index 0000000..b867c0d --- /dev/null +++ b/internal/service/interface.go @@ -0,0 +1,191 @@ +package service + +import ( + "context" + "time" + + "gorm.io/gorm" + "stream.api/internal/database/model" + "stream.api/internal/dto" + "stream.api/internal/repository" + renderworkflow "stream.api/internal/workflow/render" +) + +type PaymentHistoryRow = repository.PaymentHistoryRow +type CreateVideoInput = renderworkflow.CreateVideoInput +type CreateVideoResult = renderworkflow.CreateVideoResult + +var ( + ErrUserNotFound = renderworkflow.ErrUserNotFound + ErrAdTemplateNotFound = renderworkflow.ErrAdTemplateNotFound + ErrJobServiceUnavailable = renderworkflow.ErrJobServiceUnavailable +) + +type PaymentRepository interface { + ListHistoryByUser(ctx context.Context, userID string, subscriptionKind string, walletTopupKind string, topupType string, limit int32, offset int) ([]PaymentHistoryRow, int64, error) + ListForAdmin(ctx context.Context, userID string, status string, limit int32, offset int) ([]model.Payment, int64, error) + CountAll(ctx context.Context) (int64, error) + SumSuccessfulAmount(ctx context.Context) (float64, error) + GetByIDAndUser(ctx context.Context, paymentID string, userID string) (*model.Payment, error) + GetByID(ctx context.Context, paymentID string) (*model.Payment, error) + GetStandaloneTopupByIDAndUser(ctx context.Context, id string, userID string, topupType string) (*model.WalletTransaction, error) + GetSubscriptionByPaymentID(ctx context.Context, paymentID string) (*model.PlanSubscription, error) + CountByPlanID(ctx context.Context, planID string) (int64, error) + CreatePayment(ctx context.Context, payment *model.Payment) error + Save(ctx context.Context, payment *model.Payment) error + CreatePaymentTx(tx *gorm.DB, ctx context.Context, payment *model.Payment) error + CreateWalletTransactionTx(tx *gorm.DB, ctx context.Context, txRecord *model.WalletTransaction) error + CreatePlanSubscriptionTx(tx *gorm.DB, ctx context.Context, subscription *model.PlanSubscription) error + UpdateUserPlanID(ctx context.Context, userID string, planID string) error + UpdateUserPlanIDTx(tx *gorm.DB, ctx context.Context, userID string, planID string) error + CreateNotificationTx(tx *gorm.DB, ctx context.Context, notification *model.Notification) error + CreateWalletTopupAndNotification(ctx context.Context, userID string, transaction *model.WalletTransaction, notification *model.Notification) error + ExecuteSubscriptionPayment(ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, invoiceID string, now time.Time, validateFunding func(currentWalletBalance float64) (float64, error)) (*model.PlanSubscription, float64, error) +} + +type AccountRepository interface { + DeleteUserAccount(ctx context.Context, userID string) error + ClearUserData(ctx context.Context, userID string) error +} + +type NotificationRepository interface { + ListByUser(ctx context.Context, userID string) ([]model.Notification, error) + MarkReadByIDAndUser(ctx context.Context, id string, userID string) (int64, error) + MarkAllReadByUser(ctx context.Context, userID string) error + DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) + DeleteAllByUser(ctx context.Context, userID string) error +} + +type DomainRepository interface { + ListByUser(ctx context.Context, userID string) ([]model.Domain, error) + CountByUserAndName(ctx context.Context, userID string, name string) (int64, error) + Create(ctx context.Context, item *model.Domain) error + DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) +} + +type AdTemplateRepository interface { + ListByUser(ctx context.Context, userID string) ([]model.AdTemplate, error) + ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.AdTemplate, int64, error) + CountAll(ctx context.Context) (int64, error) + GetByID(ctx context.Context, id string) (*model.AdTemplate, error) + GetByIDAndUser(ctx context.Context, id string, userID string) (*model.AdTemplate, error) + ExistsByIDAndUser(ctx context.Context, id string, userID string) (bool, error) + CreateWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error + SaveWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error + DeleteByIDAndUserAndClearVideos(ctx context.Context, id string, userID string) error + DeleteByIDAndClearVideos(ctx context.Context, id string) error +} + +type PlayerConfigRepository interface { + ListByUser(ctx context.Context, userID string) ([]model.PlayerConfig, error) + ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.PlayerConfig, int64, error) + CountByUser(ctx context.Context, userID string) (int64, error) + CountByUserTx(tx *gorm.DB, ctx context.Context, userID string) (int64, error) + Create(ctx context.Context, item *model.PlayerConfig) error + CreateWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error + CreateTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error + GetByID(ctx context.Context, id string) (*model.PlayerConfig, error) + GetByIDAndUser(ctx context.Context, id string, userID string) (*model.PlayerConfig, error) + GetByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (*model.PlayerConfig, error) + Save(ctx context.Context, item *model.PlayerConfig) error + SaveWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error + SaveTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error + DeleteByID(ctx context.Context, id string) (int64, error) + DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) + DeleteByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (int64, error) + UnsetDefaultForUser(ctx context.Context, userID string, excludeID string) error + UnsetDefaultForUserTx(tx *gorm.DB, userID string, excludeID string) error + CreateManaged(ctx context.Context, userID string, item *model.PlayerConfig, validate func(*model.User, int64) error) error + DeleteManaged(ctx context.Context, userID string, id string, validate func(*model.User, int64) error) error + UpdateManaged(ctx context.Context, userID string, id string, mutateAndValidate func(*model.PlayerConfig, *model.User, int64) error) (*model.PlayerConfig, error) +} + +type VideoWorkflow interface { + CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error) + ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) + ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) + ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) + GetJob(ctx context.Context, id string) (*model.Job, error) + CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) + CancelJob(ctx context.Context, id string) error + RetryJob(ctx context.Context, id string) (*model.Job, error) +} + +type VideoRepository interface { + ListByUser(ctx context.Context, userID string, search string, status string, offset int, limit int) ([]model.Video, int64, error) + ListForAdmin(ctx context.Context, search string, userID string, status string, offset int, limit int) ([]model.Video, int64, error) + CountAll(ctx context.Context) (int64, error) + CountCreatedSince(ctx context.Context, since time.Time) (int64, error) + IncrementViews(ctx context.Context, videoID string, userID string) error + GetByID(ctx context.Context, videoID string) (*model.Video, error) + GetByIDAndUser(ctx context.Context, videoID string, userID string) (*model.Video, error) + UpdateByIDAndUser(ctx context.Context, videoID string, userID string, updates map[string]any) (int64, error) + CountByUser(ctx context.Context, userID string) (int64, error) + DeleteByIDAndUserWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error + DeleteByIDWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error + UpdateAdminVideo(ctx context.Context, video *model.Video, oldUserID string, oldSize int64, adTemplateID *string) error +} +type VideoWorkflowRepository interface { + CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error + GetUserByID(ctx context.Context, userID string) (*model.User, error) + MarkVideoJobFailed(ctx context.Context, videoID string) error +} +type UserRepository interface { + GetByEmail(ctx context.Context, email string) (*model.User, error) + CountByEmail(ctx context.Context, email string) (int64, error) + GetByID(ctx context.Context, userID string) (*model.User, error) + LockByIDTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) + ListForAdmin(ctx context.Context, search string, role string, limit int32, offset int) ([]model.User, int64, error) + CountAll(ctx context.Context) (int64, error) + CountCreatedSince(ctx context.Context, since time.Time) (int64, error) + SumStorageUsed(ctx context.Context) (int64, error) + GetEmailByID(ctx context.Context, userID string) (*string, error) + GetReferralSummaryByID(ctx context.Context, userID string) (*model.User, error) + CountByPlanID(ctx context.Context, planID string) (int64, error) + Create(ctx context.Context, user *model.User) error + UpdateFieldsByID(ctx context.Context, userID string, updates map[string]any) error + UpdateFieldsByIDTx(tx *gorm.DB, ctx context.Context, userID string, updates map[string]any) error + UpdatePassword(ctx context.Context, userID string, passwordHash string) error + FindByReferralUsername(ctx context.Context, username string, limit int) ([]model.User, error) + CountSubscriptionsByUser(ctx context.Context, userID string) (int64, error) +} + +type UserPreferenceRepository interface { + FindOrCreateByUserID(ctx context.Context, userID string) (*model.UserPreference, error) + Save(ctx context.Context, pref *model.UserPreference) error +} + +type BillingRepository interface { + GetWalletBalance(ctx context.Context, userID string) (float64, error) + GetWalletBalanceTx(tx *gorm.DB, ctx context.Context, userID string) (float64, error) + GetLatestPlanSubscription(ctx context.Context, userID string) (*model.PlanSubscription, error) + GetLatestPlanSubscriptionTx(tx *gorm.DB, ctx context.Context, userID string) (*model.PlanSubscription, error) + CountActiveSubscriptions(ctx context.Context, now time.Time) (int64, error) +} + +type PlanRepository interface { + GetByID(ctx context.Context, planID string) (*model.Plan, error) + ListActive(ctx context.Context) ([]model.Plan, error) + ListAll(ctx context.Context) ([]model.Plan, error) + Create(ctx context.Context, plan *model.Plan) error + Save(ctx context.Context, plan *model.Plan) error + CountPaymentsByPlan(ctx context.Context, planID string) (int64, error) + CountSubscriptionsByPlan(ctx context.Context, planID string) (int64, error) + SetActive(ctx context.Context, planID string, isActive bool) error + DeleteByID(ctx context.Context, planID string) error +} + +type JobRepository interface { + Create(ctx context.Context, job *model.Job) error + GetByID(ctx context.Context, id string) (*model.Job, error) + GetLatestByVideoID(ctx context.Context, videoID string) (*model.Job, error) + ListByCursor(ctx context.Context, agentID string, cursorTime time.Time, cursorID string, limit int) ([]*model.Job, bool, error) + ListByOffset(ctx context.Context, agentID string, offset int, limit int) ([]*model.Job, int64, error) + Save(ctx context.Context, job *model.Job) error + UpdateVideoStatus(ctx context.Context, videoID string, statusValue string, processingStatus string) error +} + +type AgentRuntime interface { + ListAgentsWithStats() []*dto.AgentWithStats + SendCommand(agentID string, cmd string) bool +} diff --git a/internal/service/oauth_helpers.go b/internal/service/oauth_helpers.go new file mode 100644 index 0000000..44ba49f --- /dev/null +++ b/internal/service/oauth_helpers.go @@ -0,0 +1,18 @@ +package service + +import ( + "crypto/rand" + "encoding/base64" +) + +func generateOAuthState() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func googleOAuthStateCacheKey(state string) string { + return "google_oauth_state:" + state +} diff --git a/internal/service/payment_helpers.go b/internal/service/payment_helpers.go index 5ddbb7d..74b49e1 100644 --- a/internal/service/payment_helpers.go +++ b/internal/service/payment_helpers.go @@ -15,7 +15,6 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "gorm.io/gorm" - "gorm.io/gorm/clause" appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" ) @@ -33,9 +32,9 @@ func statusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int, return status.Error(grpcCode, message) } -func (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) { - var planRecord model.Plan - if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { +func (s *paymentsAppService) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) { + planRecord, err := s.planRepository.GetByID(ctx, planID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Plan not found") } @@ -45,12 +44,12 @@ func (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string) if planRecord.IsActive == nil || !*planRecord.IsActive { return nil, status.Error(codes.InvalidArgument, "Plan is not active") } - return &planRecord, nil + return planRecord, nil } func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) { - var planRecord model.Plan - if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { + planRecord, err := s.planRepository.GetByID(ctx, planID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "Plan not found") } @@ -59,18 +58,18 @@ func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string if planRecord.IsActive == nil || !*planRecord.IsActive { return nil, status.Error(codes.InvalidArgument, "Plan is not active") } - return &planRecord, nil + return planRecord, nil } func (s *appServices) loadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) { - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, userID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } return nil, status.Error(codes.Internal, "Failed to create payment") } - return &user, nil + return user, nil } func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecutionInput) (*paymentExecutionResult, error) { @@ -101,69 +100,27 @@ func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecu InvoiceID: invoiceID, } - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if _, err := lockUserForUpdate(ctx, tx, input.UserID); err != nil { - return err - } - - newExpiry, err := loadPaymentExpiry(ctx, tx, input.UserID, input.TermMonths, now) - if err != nil { - return err - } - currentWalletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID) - if err != nil { - return err - } - validatedTopupAmount, err := validatePaymentFunding(ctx, input, totalAmount, currentWalletBalance) - if err != nil { - return err - } - if err := tx.Create(paymentRecord).Error; err != nil { - return err - } - if err := createPaymentWalletTransactions(tx, input, paymentRecord, totalAmount, validatedTopupAmount, currency); err != nil { - return err - } - subscription := buildPaymentSubscription(input, paymentRecord, totalAmount, validatedTopupAmount, now, newExpiry) - if err := tx.Create(subscription).Error; err != nil { - return err - } - if err := tx.Model(&model.User{}).Where("id = ?", input.UserID).Update("plan_id", input.Plan.ID).Error; err != nil { - return err - } - notification := buildSubscriptionNotification(input.UserID, paymentRecord.ID, invoiceID, input.Plan, subscription) - if err := tx.Create(notification).Error; err != nil { - return err - } - if _, err := s.maybeGrantReferralReward(ctx, tx, input, paymentRecord, subscription); err != nil { - return err - } - walletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID) - if err != nil { - return err - } - result.Subscription = subscription - result.WalletBalance = walletBalance - return nil - }) + subscription, walletBalance, err := s.paymentRepository.ExecuteSubscriptionPayment( + ctx, + input.UserID, + input.Plan, + input.TermMonths, + input.PaymentMethod, + paymentRecord, + invoiceID, + now, + func(currentWalletBalance float64) (float64, error) { + return validatePaymentFunding(ctx, input, totalAmount, currentWalletBalance) + }, + ) if err != nil { return nil, err } + result.Subscription = subscription + result.WalletBalance = walletBalance return result, nil } -func loadPaymentExpiry(ctx context.Context, tx *gorm.DB, userID string, termMonths int32, now time.Time) (time.Time, error) { - currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return time.Time{}, err - } - baseExpiry := now - if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) { - baseExpiry = currentSubscription.ExpiresAt.UTC() - } - return baseExpiry.AddDate(0, int(termMonths), 0), nil -} - func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) { shortfall := maxFloat(totalAmount-currentWalletBalance, 0) if input.PaymentMethod == paymentMethodWallet && shortfall > 0 { @@ -206,73 +163,7 @@ func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, to return topupAmount, nil } -func createPaymentWalletTransactions(tx *gorm.DB, input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error { - if input.PaymentMethod == paymentMethodTopup { - topupTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: input.UserID, - Type: walletTransactionTypeTopup, - Amount: topupAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", input.Plan.Name, input.TermMonths)), - PaymentID: &paymentRecord.ID, - PlanID: &input.Plan.ID, - TermMonths: int32Ptr(input.TermMonths), - } - if err := tx.Create(topupTransaction).Error; err != nil { - return err - } - } - debitTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: input.UserID, - Type: walletTransactionTypeSubscriptionDebit, - Amount: -totalAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", input.Plan.Name, input.TermMonths)), - PaymentID: &paymentRecord.ID, - PlanID: &input.Plan.ID, - TermMonths: int32Ptr(input.TermMonths), - } - return tx.Create(debitTransaction).Error -} - -func buildPaymentSubscription(input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, now, newExpiry time.Time) *model.PlanSubscription { - return &model.PlanSubscription{ - ID: uuid.New().String(), - UserID: input.UserID, - PaymentID: paymentRecord.ID, - PlanID: input.Plan.ID, - TermMonths: input.TermMonths, - PaymentMethod: input.PaymentMethod, - WalletAmount: totalAmount, - TopupAmount: topupAmount, - StartedAt: now, - ExpiresAt: newExpiry, - } -} - -func buildSubscriptionNotification(userID, paymentID, invoiceID string, planRecord *model.Plan, subscription *model.PlanSubscription) *model.Notification { - return &model.Notification{ - ID: uuid.New().String(), - UserID: userID, - Type: "billing.subscription", - Title: "Subscription activated", - Message: fmt.Sprintf("Your subscription to %s is active until %s.", planRecord.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")), - Metadata: model.StringPtr(mustMarshalJSON(map[string]any{ - "payment_id": paymentID, - "invoice_id": invoiceID, - "plan_id": planRecord.ID, - "term_months": subscription.TermMonths, - "payment_method": subscription.PaymentMethod, - "wallet_amount": subscription.WalletAmount, - "topup_amount": subscription.TopupAmount, - "plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339), - })), - } -} - -func (s *appServices) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) { +func (s *paymentsAppService) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) { details, err := s.loadPaymentInvoiceDetails(ctx, paymentRecord) if err != nil { return "", "", err @@ -324,15 +215,15 @@ func buildTopupInvoice(transaction *model.WalletTransaction) string { }, "\n") } -func (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { +func (s *paymentsAppService) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { details := &paymentInvoiceDetails{ PlanName: "Unknown plan", PaymentMethod: paymentMethodWallet, } if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" { - var planRecord model.Plan - if err := s.db.WithContext(ctx).Where("id = ?", *paymentRecord.PlanID).First(&planRecord).Error; err != nil { + planRecord, err := s.planRepository.GetByID(ctx, *paymentRecord.PlanID) + if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -341,11 +232,8 @@ func (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentReco } } - var subscription model.PlanSubscription - if err := s.db.WithContext(ctx). - Where("payment_id = ?", paymentRecord.ID). - Order("created_at DESC"). - First(&subscription).Error; err != nil { + subscription, err := s.paymentRepository.GetSubscriptionByPaymentID(ctx, paymentRecord.ID) + if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } @@ -369,27 +257,6 @@ func isAllowedTermMonths(value int32) bool { return ok } -func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) { - if tx.Dialector.Name() == "sqlite" { - res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID) - if res.Error != nil { - return nil, res.Error - } - if res.RowsAffected == 0 { - return nil, gorm.ErrRecordNotFound - } - } - - var user model.User - if err := tx.WithContext(ctx). - Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ?", userID). - First(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - func maxFloat(left, right float64) float64 { if left > right { return left diff --git a/internal/service/payment_proto_helpers.go b/internal/service/payment_proto_helpers.go new file mode 100644 index 0000000..6e933b7 --- /dev/null +++ b/internal/service/payment_proto_helpers.go @@ -0,0 +1,128 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + appv1 "stream.api/internal/api/proto/app/v1" + "stream.api/internal/database/model" +) + +func toProtoPayment(item *model.Payment) *appv1.Payment { + if item == nil { + return nil + } + return &appv1.Payment{ + Id: item.ID, + UserId: item.UserID, + PlanId: item.PlanID, + Amount: item.Amount, + Currency: normalizeCurrency(item.Currency), + Status: normalizePaymentStatus(item.Status), + Provider: strings.ToUpper(stringValue(item.Provider)), + TransactionId: item.TransactionID, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()), + } +} + +func toProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription { + if item == nil { + return nil + } + return &appv1.PlanSubscription{ + Id: item.ID, + UserId: item.UserID, + PaymentId: item.PaymentID, + PlanId: item.PlanID, + TermMonths: item.TermMonths, + PaymentMethod: item.PaymentMethod, + WalletAmount: item.WalletAmount, + TopupAmount: item.TopupAmount, + StartedAt: timestamppb.New(item.StartedAt.UTC()), + ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()), + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(item.UpdatedAt), + } +} + +func toProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction { + if item == nil { + return nil + } + return &appv1.WalletTransaction{ + Id: item.ID, + UserId: item.UserID, + Type: item.Type, + Amount: item.Amount, + Currency: normalizeCurrency(item.Currency), + Note: item.Note, + PaymentId: item.PaymentID, + PlanId: item.PlanID, + TermMonths: item.TermMonths, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timeToProto(item.UpdatedAt), + } +} + +func normalizePaymentStatus(status *string) string { + value := strings.ToLower(strings.TrimSpace(stringValue(status))) + switch value { + case "success", "succeeded", "paid": + return "success" + case "failed", "error", "canceled", "cancelled": + return "failed" + case "pending", "processing": + return "pending" + default: + if value == "" { + return "success" + } + return value + } +} + +func normalizeCurrency(currency *string) string { + value := strings.ToUpper(strings.TrimSpace(stringValue(currency))) + if value == "" { + return "USD" + } + return value +} + +func normalizePaymentMethod(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case paymentMethodWallet: + return paymentMethodWallet + case paymentMethodTopup: + return paymentMethodTopup + default: + return "" + } +} + +func normalizeOptionalPaymentMethod(value *string) *string { + normalized := normalizePaymentMethod(stringValue(value)) + if normalized == "" { + return nil + } + return &normalized +} + +func buildInvoiceID(id string) string { + trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "") + if len(trimmed) > 12 { + trimmed = trimmed[:12] + } + return "INV-" + strings.ToUpper(trimmed) +} + +func buildTransactionID(prefix string) string { + return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) +} + +func buildInvoiceFilename(id string) string { + return fmt.Sprintf("invoice-%s.txt", id) +} diff --git a/internal/service/preferences_helpers.go b/internal/service/preferences_helpers.go index cb7282d..8a3bdb4 100644 --- a/internal/service/preferences_helpers.go +++ b/internal/service/preferences_helpers.go @@ -4,7 +4,6 @@ import ( "context" "strings" - "gorm.io/gorm" "stream.api/internal/database/model" "stream.api/pkg/logger" ) @@ -18,12 +17,12 @@ type updatePreferencesInput struct { Locale *string } -func loadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) { - return model.FindOrCreateUserPreference(ctx, db, userID) +func loadUserPreferences(ctx context.Context, prefRepo UserPreferenceRepository, userID string) (*model.UserPreference, error) { + return prefRepo.FindOrCreateByUserID(ctx, userID) } -func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) { - pref, err := model.FindOrCreateUserPreference(ctx, db, userID) +func updateUserPreferences(ctx context.Context, prefRepo UserPreferenceRepository, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) { + pref, err := prefRepo.FindOrCreateByUserID(ctx, userID) if err != nil { l.Error("Failed to load preferences", "error", err) return nil, err @@ -54,7 +53,7 @@ func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, us pref.Locale = model.StringPtr(model.StringValue(pref.Language)) } - if err := db.WithContext(ctx).Save(pref).Error; err != nil { + if err := prefRepo.Save(ctx, pref); err != nil { l.Error("Failed to save preferences", "error", err) return nil, err } diff --git a/internal/service/profile_helpers.go b/internal/service/profile_helpers.go index f4afcad..64bdd52 100644 --- a/internal/service/profile_helpers.go +++ b/internal/service/profile_helpers.go @@ -7,7 +7,6 @@ import ( "gorm.io/gorm" "stream.api/internal/database/model" - "stream.api/internal/database/query" "stream.api/pkg/logger" ) @@ -23,7 +22,7 @@ type updateProfileInput struct { Locale *string } -func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) { +func updateUserProfile(ctx context.Context, userRepo UserRepository, prefRepo UserPreferenceRepository, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) { updates := map[string]any{} if req.Username != nil { username := strings.TrimSpace(*req.Username) @@ -38,7 +37,7 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID } if len(updates) > 0 { - if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { + if err := userRepo.UpdateFieldsByID(ctx, userID, updates); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, errEmailAlreadyRegistered } @@ -47,7 +46,7 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID } } - pref, err := model.FindOrCreateUserPreference(ctx, db, userID) + pref, err := prefRepo.FindOrCreateByUserID(ctx, userID) if err != nil { l.Error("Failed to load user preference", "error", err) return nil, err @@ -71,17 +70,11 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID prefChanged = true } if prefChanged { - if err := db.WithContext(ctx).Save(pref).Error; err != nil { + if err := prefRepo.Save(ctx, pref); err != nil { l.Error("Failed to save user preference", "error", err) return nil, err } } - u := query.User - user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First() - if err != nil { - return nil, err - } - - return user, nil + return userRepo.GetByID(ctx, userID) } diff --git a/internal/service/proto_helpers.go b/internal/service/proto_helpers.go deleted file mode 100644 index a939bc4..0000000 --- a/internal/service/proto_helpers.go +++ /dev/null @@ -1,570 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/base64" - "fmt" - "net/url" - "strings" - "time" - - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/timestamppb" - "gorm.io/gorm" - appv1 "stream.api/internal/api/proto/app/v1" - "stream.api/internal/database/model" -) - -func ensurePaidPlan(user *model.User) error { - if user == nil { - return status.Error(codes.Unauthenticated, "Unauthorized") - } - if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" { - return status.Error(codes.PermissionDenied, adTemplateUpgradeRequiredMessage) - } - return nil -} - -func playerConfigActionAllowed(user *model.User, configCount int64, action string) error { - if user == nil { - return status.Error(codes.Unauthenticated, "Unauthorized") - } - if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" { - return nil - } - - switch action { - case "create": - if configCount > 0 { - return status.Error(codes.FailedPrecondition, playerConfigFreePlanLimitMessage) - } - return nil - case "delete": - return nil - case "update", "set-default", "toggle-active": - if configCount > 1 { - return status.Error(codes.FailedPrecondition, playerConfigFreePlanReconciliationMessage) - } - return nil - default: - return nil - } -} - -func safeRole(role *string) string { - if role == nil || strings.TrimSpace(*role) == "" { - return "USER" - } - return *role -} - -func generateOAuthState() (string, error) { - buffer := make([]byte, 32) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return base64.RawURLEncoding.EncodeToString(buffer), nil -} - -func googleOAuthStateCacheKey(state string) string { - return "google_oauth_state:" + state -} - -func stringPointerOrNil(value string) *string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil - } - return &trimmed -} - -func toProtoVideo(item *model.Video, jobID ...string) *appv1.Video { - if item == nil { - return nil - } - statusValue := stringValue(item.Status) - if statusValue == "" { - statusValue = "ready" - } - var linkedJobID *string - if len(jobID) > 0 { - linkedJobID = stringPointerOrNil(jobID[0]) - } - return &appv1.Video{ - Id: item.ID, - UserId: item.UserID, - Title: item.Title, - Description: item.Description, - Url: item.URL, - Status: strings.ToLower(statusValue), - Size: item.Size, - Duration: item.Duration, - Format: item.Format, - Thumbnail: item.Thumbnail, - ProcessingStatus: item.ProcessingStatus, - StorageType: item.StorageType, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()), - JobId: linkedJobID, - } -} - -func (s *appServices) buildVideo(ctx context.Context, video *model.Video) (*appv1.Video, error) { - if video == nil { - return nil, nil - } - jobID, err := s.loadLatestVideoJobID(ctx, video.ID) - if err != nil { - return nil, err - } - if jobID != nil { - return toProtoVideo(video, *jobID), nil - } - return toProtoVideo(video), nil -} - -func normalizeVideoStatusValue(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "processing", "pending": - return "processing" - case "failed", "error": - return "failed" - default: - return "ready" - } -} - -func detectStorageType(rawURL string) string { - if shouldDeleteStoredObject(rawURL) { - return "S3" - } - return "WORKER" -} - -func shouldDeleteStoredObject(rawURL string) bool { - trimmed := strings.TrimSpace(rawURL) - if trimmed == "" { - return false - } - parsed, err := url.Parse(trimmed) - if err != nil { - return !strings.HasPrefix(trimmed, "/") - } - return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/") -} - -func extractObjectKey(rawURL string) string { - trimmed := strings.TrimSpace(rawURL) - if trimmed == "" { - return "" - } - parsed, err := url.Parse(trimmed) - if err != nil { - return trimmed - } - return strings.TrimPrefix(parsed.Path, "/") -} - -func protoUserFromPayload(user *userPayload) *appv1.User { - if user == nil { - return nil - } - return &appv1.User{ - Id: user.ID, - Email: user.Email, - Username: user.Username, - Avatar: user.Avatar, - Role: user.Role, - GoogleId: user.GoogleID, - StorageUsed: user.StorageUsed, - PlanId: user.PlanID, - PlanStartedAt: timeToProto(user.PlanStartedAt), - PlanExpiresAt: timeToProto(user.PlanExpiresAt), - PlanTermMonths: user.PlanTermMonths, - PlanPaymentMethod: user.PlanPaymentMethod, - PlanExpiringSoon: user.PlanExpiringSoon, - WalletBalance: user.WalletBalance, - Language: user.Language, - Locale: user.Locale, - CreatedAt: timeToProto(user.CreatedAt), - UpdatedAt: timestamppb.New(user.UpdatedAt), - } -} - -func toProtoUser(user *userPayload) *appv1.User { - return protoUserFromPayload(user) -} - -func toProtoPreferences(pref *model.UserPreference) *appv1.Preferences { - if pref == nil { - return nil - } - return &appv1.Preferences{ - EmailNotifications: boolValue(pref.EmailNotifications), - PushNotifications: boolValue(pref.PushNotifications), - MarketingNotifications: pref.MarketingNotifications, - TelegramNotifications: pref.TelegramNotifications, - Language: model.StringValue(pref.Language), - Locale: model.StringValue(pref.Locale), - } -} - -func toProtoNotification(item model.Notification) *appv1.Notification { - return &appv1.Notification{ - Id: item.ID, - Type: normalizeNotificationType(item.Type), - Title: item.Title, - Message: item.Message, - Read: item.IsRead, - ActionUrl: item.ActionURL, - ActionLabel: item.ActionLabel, - CreatedAt: timeToProto(item.CreatedAt), - } -} - -func toProtoDomain(item *model.Domain) *appv1.Domain { - if item == nil { - return nil - } - return &appv1.Domain{ - Id: item.ID, - Name: item.Name, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(item.UpdatedAt), - } -} - -func toProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate { - if item == nil { - return nil - } - return &appv1.AdTemplate{ - Id: item.ID, - Name: item.Name, - Description: item.Description, - VastTagUrl: item.VastTagURL, - AdFormat: model.StringValue(item.AdFormat), - Duration: int64PtrToInt32Ptr(item.Duration), - IsActive: boolValue(item.IsActive), - IsDefault: item.IsDefault, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(item.UpdatedAt), - } -} - -func toProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig { - if item == nil { - return nil - } - return &appv1.PlayerConfig{ - Id: item.ID, - Name: item.Name, - Description: item.Description, - Autoplay: item.Autoplay, - Loop: item.Loop, - Muted: item.Muted, - ShowControls: boolValue(item.ShowControls), - Pip: boolValue(item.Pip), - Airplay: boolValue(item.Airplay), - Chromecast: boolValue(item.Chromecast), - IsActive: boolValue(item.IsActive), - IsDefault: item.IsDefault, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(&item.UpdatedAt), - EncrytionM3U8: boolValue(item.EncrytionM3u8), - LogoUrl: nullableTrimmedString(item.LogoURL), - } -} - -func toProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig { - if item == nil { - return nil - } - return &appv1.AdminPlayerConfig{ - Id: item.ID, - UserId: item.UserID, - Name: item.Name, - Description: item.Description, - Autoplay: item.Autoplay, - Loop: item.Loop, - Muted: item.Muted, - ShowControls: boolValue(item.ShowControls), - Pip: boolValue(item.Pip), - Airplay: boolValue(item.Airplay), - Chromecast: boolValue(item.Chromecast), - IsActive: boolValue(item.IsActive), - IsDefault: item.IsDefault, - OwnerEmail: ownerEmail, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(&item.UpdatedAt), - EncrytionM3U8: boolValue(item.EncrytionM3u8), - LogoUrl: nullableTrimmedString(item.LogoURL), - } -} - -func toProtoPlan(item *model.Plan) *appv1.Plan { - if item == nil { - return nil - } - return &appv1.Plan{ - Id: item.ID, - Name: item.Name, - Description: item.Description, - Price: item.Price, - Cycle: item.Cycle, - StorageLimit: item.StorageLimit, - UploadLimit: item.UploadLimit, - DurationLimit: item.DurationLimit, - QualityLimit: item.QualityLimit, - Features: item.Features, - IsActive: boolValue(item.IsActive), - } -} - -func toProtoPayment(item *model.Payment) *appv1.Payment { - if item == nil { - return nil - } - return &appv1.Payment{ - Id: item.ID, - UserId: item.UserID, - PlanId: item.PlanID, - Amount: item.Amount, - Currency: normalizeCurrency(item.Currency), - Status: normalizePaymentStatus(item.Status), - Provider: strings.ToUpper(stringValue(item.Provider)), - TransactionId: item.TransactionID, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()), - } -} - -func toProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription { - if item == nil { - return nil - } - return &appv1.PlanSubscription{ - Id: item.ID, - UserId: item.UserID, - PaymentId: item.PaymentID, - PlanId: item.PlanID, - TermMonths: item.TermMonths, - PaymentMethod: item.PaymentMethod, - WalletAmount: item.WalletAmount, - TopupAmount: item.TopupAmount, - StartedAt: timestamppb.New(item.StartedAt.UTC()), - ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()), - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(item.UpdatedAt), - } -} - -func toProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction { - if item == nil { - return nil - } - return &appv1.WalletTransaction{ - Id: item.ID, - UserId: item.UserID, - Type: item.Type, - Amount: item.Amount, - Currency: normalizeCurrency(item.Currency), - Note: item.Note, - PaymentId: item.PaymentID, - PlanId: item.PlanID, - TermMonths: item.TermMonths, - CreatedAt: timeToProto(item.CreatedAt), - UpdatedAt: timeToProto(item.UpdatedAt), - } -} - -func timeToProto(value *time.Time) *timestamppb.Timestamp { - if value == nil { - return nil - } - return timestamppb.New(value.UTC()) -} - -func boolValue(value *bool) bool { - return value != nil && *value -} - -func stringValue(value *string) string { - if value == nil { - return "" - } - return *value -} - -func int32PtrToInt64Ptr(value *int32) *int64 { - if value == nil { - return nil - } - converted := int64(*value) - return &converted -} - -func int64PtrToInt32Ptr(value *int64) *int32 { - if value == nil { - return nil - } - converted := int32(*value) - return &converted -} - -func int32Ptr(value int32) *int32 { - return &value -} - -func protoStringValue(value *string) string { - if value == nil { - return "" - } - return strings.TrimSpace(*value) -} - -func nullableTrimmedStringPtr(value *string) *string { - if value == nil { - return nil - } - trimmed := strings.TrimSpace(*value) - if trimmed == "" { - return nil - } - return &trimmed -} - -func nullableTrimmedString(value *string) *string { - if value == nil { - return nil - } - trimmed := strings.TrimSpace(*value) - if trimmed == "" { - return nil - } - return &trimmed -} - -func normalizeNotificationType(value string) string { - lower := strings.ToLower(strings.TrimSpace(value)) - switch { - case strings.Contains(lower, "video"): - return "video" - case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"): - return "payment" - case strings.Contains(lower, "warning"): - return "warning" - case strings.Contains(lower, "error"): - return "error" - case strings.Contains(lower, "success"): - return "success" - case strings.Contains(lower, "system"): - return "system" - default: - return "info" - } -} - -func normalizeDomain(value string) string { - normalized := strings.TrimSpace(strings.ToLower(value)) - normalized = strings.TrimPrefix(normalized, "https://") - normalized = strings.TrimPrefix(normalized, "http://") - normalized = strings.TrimPrefix(normalized, "www.") - normalized = strings.TrimSuffix(normalized, "/") - return normalized -} - -func normalizeAdFormat(value string) string { - switch strings.TrimSpace(strings.ToLower(value)) { - case "mid-roll", "post-roll": - return strings.TrimSpace(strings.ToLower(value)) - default: - return "pre-roll" - } -} - -func adTemplateIsActive(value *bool) bool { - return value == nil || *value -} - -func playerConfigIsActive(value *bool) bool { - return value == nil || *value -} - -func unsetDefaultTemplates(tx *gorm.DB, userID, excludeID string) error { - query := tx.Model(&model.AdTemplate{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} - -func unsetDefaultPlayerConfigs(tx *gorm.DB, userID, excludeID string) error { - query := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", userID) - if excludeID != "" { - query = query.Where("id <> ?", excludeID) - } - return query.Update("is_default", false).Error -} - -func normalizePaymentStatus(status *string) string { - value := strings.ToLower(strings.TrimSpace(stringValue(status))) - switch value { - case "success", "succeeded", "paid": - return "success" - case "failed", "error", "canceled", "cancelled": - return "failed" - case "pending", "processing": - return "pending" - default: - if value == "" { - return "success" - } - return value - } -} - -func normalizeCurrency(currency *string) string { - value := strings.ToUpper(strings.TrimSpace(stringValue(currency))) - if value == "" { - return "USD" - } - return value -} - -func normalizePaymentMethod(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case paymentMethodWallet: - return paymentMethodWallet - case paymentMethodTopup: - return paymentMethodTopup - default: - return "" - } -} - -func normalizeOptionalPaymentMethod(value *string) *string { - normalized := normalizePaymentMethod(stringValue(value)) - if normalized == "" { - return nil - } - return &normalized -} - -func buildInvoiceID(id string) string { - trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "") - if len(trimmed) > 12 { - trimmed = trimmed[:12] - } - return "INV-" + strings.ToUpper(trimmed) -} - -func buildTransactionID(prefix string) string { - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) -} - -func buildInvoiceFilename(id string) string { - return fmt.Sprintf("invoice-%s.txt", id) -} diff --git a/internal/service/referral_helpers.go b/internal/service/referral_helpers.go index 38b4641..d0a51e6 100644 --- a/internal/service/referral_helpers.go +++ b/internal/service/referral_helpers.go @@ -74,19 +74,7 @@ func (s *appServices) buildReferralShareLink(username *string) *string { } func (s *appServices) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) { - trimmed := strings.TrimSpace(username) - if trimmed == "" { - return nil, nil - } - var users []model.User - if err := s.db.WithContext(ctx). - Where("LOWER(username) = LOWER(?)", trimmed). - Order("created_at ASC, id ASC"). - Limit(2). - Find(&users).Error; err != nil { - return nil, err - } - return users, nil + return s.userRepository.FindByReferralUsername(ctx, username, 2) } func (s *appServices) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) { @@ -160,7 +148,7 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB, return &referralRewardResult{}, nil } - referee, err := lockUserForUpdate(ctx, tx, input.UserID) + referee, err := s.userRepository.LockByIDTx(tx, ctx, input.UserID) if err != nil { return nil, err } @@ -171,18 +159,15 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB, return &referralRewardResult{}, nil } - var subscriptionCount int64 - if err := tx.WithContext(ctx). - Model(&model.PlanSubscription{}). - Where("user_id = ?", referee.ID). - Count(&subscriptionCount).Error; err != nil { + subscriptionCount, err := s.userRepository.CountSubscriptionsByUser(ctx, referee.ID) + if err != nil { return nil, err } if subscriptionCount != 1 { return &referralRewardResult{}, nil } - referrer, err := lockUserForUpdate(ctx, tx, strings.TrimSpace(*referee.ReferredByUserID)) + referrer, err := s.userRepository.LockByIDTx(tx, ctx, strings.TrimSpace(*referee.ReferredByUserID)) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return &referralRewardResult{}, nil @@ -217,10 +202,10 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB, PaymentID: &paymentRecord.ID, PlanID: &input.Plan.ID, } - if err := tx.Create(rewardTransaction).Error; err != nil { + if err := s.paymentRepository.CreateWalletTransactionTx(tx, ctx, rewardTransaction); err != nil { return nil, err } - if err := tx.Create(buildReferralRewardNotification(referrer.ID, rewardAmount, referee, paymentRecord)).Error; err != nil { + if err := s.paymentRepository.CreateNotificationTx(tx, ctx, buildReferralRewardNotification(referrer.ID, rewardAmount, referee, paymentRecord)); err != nil { return nil, err } @@ -230,7 +215,7 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB, "referral_reward_payment_id": paymentRecord.ID, "referral_reward_amount": rewardAmount, } - if err := tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", referee.ID).Updates(updates).Error; err != nil { + if err := s.userRepository.UpdateFieldsByIDTx(tx, ctx, referee.ID, updates); err != nil { return nil, err } referee.ReferralRewardGrantedAt = &now diff --git a/internal/service/service_account.go b/internal/service/service_account.go index 16e8855..b789996 100644 --- a/internal/service/service_account.go +++ b/internal/service/service_account.go @@ -7,46 +7,42 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/wrapperspb" - "gorm.io/gorm" appv1 "stream.api/internal/api/proto/app/v1" - "stream.api/internal/database/model" - "stream.api/internal/database/query" ) -func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) { +func (s *accountAppService) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - payload, err := buildUserPayload(ctx, s.db, result.User) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, result.User) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil } -func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) { +func (s *accountAppService) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) { _, err := s.authenticator.RequireTrustedMetadata(ctx) if err != nil { return nil, err } - u := query.User - user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First() + user, err := s.userRepository.GetByID(ctx, req.Value) if err != nil { return nil, status.Error(codes.Unauthenticated, "Unauthorized") } - payload, err := buildUserPayload(ctx, s.db, user) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } return toProtoUser(payload), nil } -func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) { +func (s *accountAppService) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{ + updatedUser, err := updateUserProfile(ctx, s.userRepository, s.preferenceRepository, s.logger, result.UserID, updateProfileInput{ Username: req.Username, Email: req.Email, Language: req.Language, @@ -61,103 +57,57 @@ func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) } } - payload, err := buildUserPayload(ctx, s.db, updatedUser) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, updatedUser) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil } -func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) { +func (s *accountAppService) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } userID := result.UserID - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { - return err - } - if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil { - return err - } - return nil - }); err != nil { + if err := s.accountRepository.DeleteUserAccount(ctx, userID); err != nil { s.logger.Error("Failed to delete user", "error", err) return nil, status.Error(codes.Internal, "Failed to delete account") } return messageResponse("Account deleted successfully"), nil } -func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) { +func (s *accountAppService) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } userID := result.UserID - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil { - return err - } - if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil { - return err - } - if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{"storage_used": 0}).Error; err != nil { - return err - } - return nil - }); err != nil { + if err := s.accountRepository.ClearUserData(ctx, userID); err != nil { s.logger.Error("Failed to clear user data", "error", err) return nil, status.Error(codes.Internal, "Failed to clear data") } return messageResponse("Data cleared successfully"), nil } -func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) { +func (s *accountAppService) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - pref, err := loadUserPreferences(ctx, s.db, result.UserID) + pref, err := loadUserPreferences(ctx, s.preferenceRepository, result.UserID) if err != nil { return nil, status.Error(codes.Internal, "Failed to load preferences") } return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil } -func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) { +func (s *accountAppService) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{ + pref, err := updateUserPreferences(ctx, s.preferenceRepository, s.logger, result.UserID, updatePreferencesInput{ EmailNotifications: req.EmailNotifications, PushNotifications: req.PushNotifications, MarketingNotifications: req.MarketingNotifications, @@ -170,12 +120,12 @@ func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePr } return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil } -func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) { +func (s *accountAppService) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - payload, err := loadUsage(ctx, s.db, s.logger, result.User) + payload, err := loadUsage(ctx, s.videoRepository, s.logger, result.User) if err != nil { return nil, status.Error(codes.Internal, "Failed to load usage") } diff --git a/internal/service/service_admin_finance_catalog.go b/internal/service/service_admin_finance_catalog.go index 5a8ac20..ab79d53 100644 --- a/internal/service/service_admin_finance_catalog.go +++ b/internal/service/service_admin_finance_catalog.go @@ -19,25 +19,11 @@ func (s *appServices) ListAdminPayments(ctx context.Context, req *appv1.ListAdmi } page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - limitInt := int(limit) userID := strings.TrimSpace(req.GetUserId()) statusFilter := strings.TrimSpace(req.GetStatus()) - db := s.db.WithContext(ctx).Model(&model.Payment{}) - if userID != "" { - db = db.Where("user_id = ?", userID) - } - if statusFilter != "" { - db = db.Where("UPPER(status) = ?", strings.ToUpper(statusFilter)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, status.Error(codes.Internal, "Failed to list payments") - } - - var payments []model.Payment - if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil { + payments, total, err := s.paymentRepository.ListForAdmin(ctx, userID, statusFilter, limit, offset) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list payments") } @@ -62,15 +48,15 @@ func (s *appServices) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPa return nil, status.Error(codes.NotFound, "Payment not found") } - var payment model.Payment - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil { + payment, err := s.paymentRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Payment not found") } return nil, status.Error(codes.Internal, "Failed to get payment") } - payload, err := s.buildAdminPayment(ctx, &payment) + payload, err := s.buildAdminPayment(ctx, payment) if err != nil { return nil, status.Error(codes.Internal, "Failed to get payment") } @@ -148,8 +134,8 @@ func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateA return nil, status.Error(codes.InvalidArgument, "Invalid payment status") } - var payment model.Payment - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil { + payment, err := s.paymentRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Payment not found") } @@ -162,12 +148,12 @@ func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateA return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead") } payment.Status = model.StringPtr(newStatus) - if err := s.db.WithContext(ctx).Save(&payment).Error; err != nil { + if err := s.paymentRepository.Save(ctx, payment); err != nil { return nil, status.Error(codes.Internal, "Failed to update payment") } } - payload, err := s.buildAdminPayment(ctx, &payment) + payload, err := s.buildAdminPayment(ctx, payment) if err != nil { return nil, status.Error(codes.Internal, "Failed to update payment") } @@ -178,8 +164,8 @@ func (s *appServices) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlan return nil, err } - var plans []model.Plan - if err := s.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil { + plans, err := s.planRepository.ListAll(ctx) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list plans") } @@ -216,7 +202,7 @@ func (s *appServices) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdmi IsActive: model.BoolPtr(req.GetIsActive()), } - if err := s.db.WithContext(ctx).Create(plan).Error; err != nil { + if err := s.planRepository.Create(ctx, plan); err != nil { return nil, status.Error(codes.Internal, "Failed to create plan") } @@ -239,8 +225,8 @@ func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdmi return nil, status.Error(codes.InvalidArgument, msg) } - var plan model.Plan - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { + plan, err := s.planRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Plan not found") } @@ -256,11 +242,11 @@ func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdmi plan.UploadLimit = req.GetUploadLimit() plan.IsActive = model.BoolPtr(req.GetIsActive()) - if err := s.db.WithContext(ctx).Save(&plan).Error; err != nil { + if err := s.planRepository.Save(ctx, plan); err != nil { return nil, status.Error(codes.Internal, "Failed to update plan") } - payload, err := s.buildAdminPlan(ctx, &plan) + payload, err := s.buildAdminPlan(ctx, plan) if err != nil { return nil, status.Error(codes.Internal, "Failed to update plan") } @@ -276,32 +262,32 @@ func (s *appServices) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdmi return nil, status.Error(codes.NotFound, "Plan not found") } - var plan model.Plan - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { + _, err := s.planRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Plan not found") } return nil, status.Error(codes.Internal, "Failed to delete plan") } - var paymentCount int64 - if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil { + paymentCount, err := s.planRepository.CountPaymentsByPlan(ctx, id) + if err != nil { return nil, status.Error(codes.Internal, "Failed to delete plan") } - var subscriptionCount int64 - if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil { + subscriptionCount, err := s.planRepository.CountSubscriptionsByPlan(ctx, id) + if err != nil { return nil, status.Error(codes.Internal, "Failed to delete plan") } if paymentCount > 0 || subscriptionCount > 0 { inactive := false - if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil { + if err := s.planRepository.SetActive(ctx, id, inactive); err != nil { return nil, status.Error(codes.Internal, "Failed to deactivate plan") } return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil } - if err := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil { + if err := s.planRepository.DeleteByID(ctx, id); err != nil { return nil, status.Error(codes.Internal, "Failed to delete plan") } return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil @@ -312,26 +298,11 @@ func (s *appServices) ListAdminAdTemplates(ctx context.Context, req *appv1.ListA } page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - limitInt := int(limit) search := strings.TrimSpace(protoStringValue(req.Search)) userID := strings.TrimSpace(protoStringValue(req.UserId)) - db := s.db.WithContext(ctx).Model(&model.AdTemplate{}) - if search != "" { - like := "%" + search + "%" - db = db.Where("name ILIKE ?", like) - } - if userID != "" { - db = db.Where("user_id = ?", userID) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, status.Error(codes.Internal, "Failed to list ad templates") - } - - var templates []model.AdTemplate - if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil { + templates, total, err := s.adTemplateRepository.ListForAdmin(ctx, search, userID, limit, offset) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list ad templates") } @@ -361,15 +332,15 @@ func (s *appServices) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdmi return nil, status.Error(codes.NotFound, "Ad template not found") } - var item model.AdTemplate - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + item, err := s.adTemplateRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Ad template not found") } return nil, status.Error(codes.Internal, "Failed to load ad template") } - payload, err := s.buildAdminAdTemplate(ctx, &item) + payload, err := s.buildAdminAdTemplate(ctx, item) if err != nil { return nil, status.Error(codes.Internal, "Failed to load ad template") } @@ -385,8 +356,8 @@ func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.Crea return nil, status.Error(codes.InvalidArgument, msg) } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId())) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } @@ -408,14 +379,7 @@ func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.Crea item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, ""); err != nil { - return err - } - } - return tx.Create(item).Error - }); err != nil { + if err := s.adTemplateRepository.CreateWithDefault(ctx, item.UserID, item); err != nil { return nil, status.Error(codes.Internal, "Failed to save ad template") } @@ -439,16 +403,16 @@ func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.Upda return nil, status.Error(codes.InvalidArgument, msg) } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId())) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } return nil, status.Error(codes.Internal, "Failed to save ad template") } - var item model.AdTemplate - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + item, err := s.adTemplateRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Ad template not found") } @@ -467,18 +431,11 @@ func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.Upda item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, item.ID); err != nil { - return err - } - } - return tx.Save(&item).Error - }); err != nil { + if err := s.adTemplateRepository.SaveWithDefault(ctx, item.UserID, item); err != nil { return nil, status.Error(codes.Internal, "Failed to save ad template") } - payload, err := s.buildAdminAdTemplate(ctx, &item) + payload, err := s.buildAdminAdTemplate(ctx, item) if err != nil { return nil, status.Error(codes.Internal, "Failed to save ad template") } @@ -494,19 +451,7 @@ func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.Dele return nil, status.Error(codes.NotFound, "Ad template not found") } - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil { - return err - } - res := tx.Where("id = ?", id).Delete(&model.AdTemplate{}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil - }) + err := s.adTemplateRepository.DeleteByIDAndClearVideos(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Ad template not found") @@ -522,26 +467,11 @@ func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.Lis } page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - limitInt := int(limit) search := strings.TrimSpace(protoStringValue(req.Search)) userID := strings.TrimSpace(protoStringValue(req.UserId)) - db := s.db.WithContext(ctx).Model(&model.PlayerConfig{}) - if search != "" { - like := "%" + search + "%" - db = db.Where("name ILIKE ?", like) - } - if userID != "" { - db = db.Where("user_id = ?", userID) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, status.Error(codes.Internal, "Failed to list player configs") - } - - var configs []model.PlayerConfig - if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil { + configs, total, err := s.playerConfigRepo.ListForAdmin(ctx, search, userID, limit, offset) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list player configs") } @@ -572,15 +502,15 @@ func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAd return nil, status.Error(codes.NotFound, "Player config not found") } - var item model.PlayerConfig - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + item, err := s.playerConfigRepo.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Player config not found") } return nil, status.Error(codes.Internal, "Failed to load player config") } - payload, err := s.buildAdminPlayerConfig(ctx, &item) + payload, err := s.buildAdminPlayerConfig(ctx, item) if err != nil { return nil, status.Error(codes.Internal, "Failed to load player config") } @@ -596,8 +526,8 @@ func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.Cr return nil, status.Error(codes.InvalidArgument, msg) } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId())) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } @@ -625,14 +555,7 @@ func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.Cr item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil { - return err - } - } - return tx.Create(item).Error - }); err != nil { + if err := s.playerConfigRepo.CreateWithDefault(ctx, item.UserID, item); err != nil { return nil, status.Error(codes.Internal, "Failed to save player config") } @@ -657,16 +580,16 @@ func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.Up return nil, status.Error(codes.InvalidArgument, msg) } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId())) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } return nil, status.Error(codes.Internal, "Failed to save player config") } - var item model.PlayerConfig - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { + item, err := s.playerConfigRepo.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Player config not found") } @@ -695,18 +618,11 @@ func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.Up item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil { - return err - } - } - return tx.Save(&item).Error - }); err != nil { + if err := s.playerConfigRepo.SaveWithDefault(ctx, item.UserID, item); err != nil { return nil, status.Error(codes.Internal, "Failed to save player config") } - payload, err := s.buildAdminPlayerConfig(ctx, &item) + payload, err := s.buildAdminPlayerConfig(ctx, item) if err != nil { return nil, status.Error(codes.Internal, "Failed to save player config") } @@ -723,11 +639,11 @@ func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.De return nil, status.Error(codes.NotFound, "Player config not found") } - res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{}) - if res.Error != nil { + rowsAffected, err := s.playerConfigRepo.DeleteByID(ctx, id) + if err != nil { return nil, status.Error(codes.Internal, "Failed to delete player config") } - if res.RowsAffected == 0 { + if rowsAffected == 0 { return nil, status.Error(codes.NotFound, "Player config not found") } diff --git a/internal/service/service_admin_jobs_agents.go b/internal/service/service_admin_jobs_agents.go index 34e7a77..74f7344 100644 --- a/internal/service/service_admin_jobs_agents.go +++ b/internal/service/service_admin_jobs_agents.go @@ -17,7 +17,7 @@ func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJob if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -32,11 +32,11 @@ func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJob err error ) if useCursorPagination { - result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize) + result, err = s.videoWorkflowService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize) } else if agentID != "" { - result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit) + result, err = s.videoWorkflowService.ListJobsByAgent(ctx, agentID, offset, limit) } else { - result, err = s.videoService.ListJobs(ctx, offset, limit) + result, err = s.videoWorkflowService.ListJobs(ctx, offset, limit) } if err != nil { if errors.Is(err, ErrInvalidJobCursor) { @@ -67,7 +67,7 @@ func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobReq if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -75,7 +75,7 @@ func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobReq if id == "" { return nil, status.Error(codes.NotFound, "Job not found") } - job, err := s.videoService.GetJob(ctx, id) + job, err := s.videoWorkflowService.GetJob(ctx, id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Job not found") @@ -95,7 +95,7 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -124,7 +124,7 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin if req.VideoId != nil { videoID = strings.TrimSpace(req.GetVideoId()) } - job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit()) + job, err := s.videoWorkflowService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit()) if err != nil { return nil, status.Error(codes.Internal, "Failed to create job") } @@ -134,7 +134,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -142,7 +142,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin if id == "" { return nil, status.Error(codes.NotFound, "Job not found") } - if err := s.videoService.CancelJob(ctx, id); err != nil { + if err := s.videoWorkflowService.CancelJob(ctx, id); err != nil { if strings.Contains(strings.ToLower(err.Error()), "not found") { return nil, status.Error(codes.NotFound, "Job not found") } @@ -154,7 +154,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -162,7 +162,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo if id == "" { return nil, status.Error(codes.NotFound, "Job not found") } - job, err := s.videoService.RetryJob(ctx, id) + job, err := s.videoWorkflowService.RetryJob(ctx, id) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "not found") { return nil, status.Error(codes.NotFound, "Job not found") diff --git a/internal/service/service_admin_users_videos.go b/internal/service/service_admin_users_videos.go index 45cace8..4c19e48 100644 --- a/internal/service/service_admin_users_videos.go +++ b/internal/service/service_admin_users_videos.go @@ -20,20 +20,55 @@ func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDa return nil, err } - dashboard := &appv1.AdminDashboard{} - db := s.db.WithContext(ctx) - - db.Model(&model.User{}).Count(&dashboard.TotalUsers) - db.Model(&model.Video{}).Count(&dashboard.TotalVideos) - db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed) - db.Model(&model.Payment{}).Count(&dashboard.TotalPayments) - db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue) - db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions) - db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates) - today := time.Now().Truncate(24 * time.Hour) - db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday) - db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday) + totalUsers, err := s.userRepository.CountAll(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + totalVideos, err := s.videoRepository.CountAll(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + totalStorageUsed, err := s.userRepository.SumStorageUsed(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + totalPayments, err := s.paymentRepository.CountAll(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + totalRevenue, err := s.paymentRepository.SumSuccessfulAmount(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + activeSubscriptions, err := s.billingRepository.CountActiveSubscriptions(ctx, time.Now()) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + totalAdTemplates, err := s.adTemplateRepository.CountAll(ctx) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + newUsersToday, err := s.userRepository.CountCreatedSince(ctx, today) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + newVideosToday, err := s.videoRepository.CountCreatedSince(ctx, today) + if err != nil { + return nil, status.Error(codes.Internal, "Failed to load dashboard") + } + + dashboard := &appv1.AdminDashboard{ + TotalUsers: totalUsers, + TotalVideos: totalVideos, + TotalStorageUsed: totalStorageUsed, + TotalPayments: totalPayments, + TotalRevenue: totalRevenue, + ActiveSubscriptions: activeSubscriptions, + TotalAdTemplates: totalAdTemplates, + NewUsersToday: newUsersToday, + NewVideosToday: newVideosToday, + } return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil } @@ -43,26 +78,11 @@ func (s *appServices) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUs } page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - limitInt := int(limit) search := strings.TrimSpace(req.GetSearch()) role := strings.TrimSpace(req.GetRole()) - db := s.db.WithContext(ctx).Model(&model.User{}) - if search != "" { - like := "%" + search + "%" - db = db.Where("email ILIKE ? OR username ILIKE ?", like, like) - } - if role != "" { - db = db.Where("UPPER(role) = ?", strings.ToUpper(role)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, status.Error(codes.Internal, "Failed to list users") - } - - var users []model.User - if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil { + users, total, err := s.userRepository.ListForAdmin(ctx, search, role, limit, offset) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list users") } @@ -87,8 +107,8 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR return nil, status.Error(codes.NotFound, "User not found") } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") } @@ -96,14 +116,13 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR } var subscription *model.PlanSubscription - var subscriptionRecord model.PlanSubscription - if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { - subscription = &subscriptionRecord + if subscriptionRecord, err := s.billingRepository.GetLatestPlanSubscription(ctx, id); err == nil { + subscription = subscriptionRecord } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.Internal, "Failed to get user") } - detail, err := s.buildAdminUserDetail(ctx, &user, subscription) + detail, err := s.buildAdminUserDetail(ctx, user, subscription) if err != nil { return nil, status.Error(codes.Internal, "Failed to get user") } @@ -144,7 +163,7 @@ func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdmi PlanID: planID, } - if err := s.db.WithContext(ctx).Create(user).Error; err != nil { + if err := s.userRepository.Create(ctx, user); err != nil { if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") } @@ -207,36 +226,38 @@ func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdmi updates["password"] = string(hashedPassword) } if len(updates) == 0 { - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") } return nil, status.Error(codes.Internal, "Failed to update user") } - payload, err := s.buildAdminUser(ctx, &user) + payload, err := s.buildAdminUser(ctx, user) if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") } return &appv1.UpdateAdminUserResponse{User: payload}, nil } - result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrDuplicatedKey) { + if _, err := s.userRepository.GetByID(ctx, id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "User not found") + } + return nil, status.Error(codes.Internal, "Failed to update user") + } + if err := s.userRepository.UpdateFieldsByID(ctx, id, updates); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.AlreadyExists, "Email already registered") } return nil, status.Error(codes.Internal, "Failed to update user") } - if result.RowsAffected == 0 { - return nil, status.Error(codes.NotFound, "User not found") - } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, id) + if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") } - payload, err := s.buildAdminUser(ctx, &user) + payload, err := s.buildAdminUser(ctx, user) if err != nil { return nil, status.Error(codes.Internal, "Failed to update user") } @@ -264,8 +285,8 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req * } } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") } @@ -274,7 +295,7 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req * updates := map[string]any{} if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) { - if referralRewardProcessed(&user) { + if referralRewardProcessed(user) { return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted") } if req.ClearReferrer != nil && req.GetClearReferrer() { @@ -300,26 +321,22 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req * } if len(updates) > 0 { - result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates) - if result.Error != nil { + if err := s.userRepository.UpdateFieldsByID(ctx, id, updates); err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") } - if result.RowsAffected == 0 { - return nil, status.Error(codes.NotFound, "User not found") - } } - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + user, err = s.userRepository.GetByID(ctx, id) + if err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") } var subscription *model.PlanSubscription - var subscriptionRecord model.PlanSubscription - if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { - subscription = &subscriptionRecord + if subscriptionRecord, err := s.billingRepository.GetLatestPlanSubscription(ctx, id); err == nil { + subscription = subscriptionRecord } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.Internal, "Failed to update referral settings") } - payload, err := s.buildAdminUserDetail(ctx, &user, subscription) + payload, err := s.buildAdminUserDetail(ctx, user, subscription) if err != nil { return nil, status.Error(codes.Internal, "Failed to update referral settings") } @@ -344,12 +361,14 @@ func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.Update return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") } - result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role) - if result.Error != nil { + if _, err := s.userRepository.GetByID(ctx, id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, status.Error(codes.NotFound, "User not found") + } return nil, status.Error(codes.Internal, "Failed to update role") } - if result.RowsAffected == 0 { - return nil, status.Error(codes.NotFound, "User not found") + if err := s.userRepository.UpdateFieldsByID(ctx, id, map[string]any{"role": role}); err != nil { + return nil, status.Error(codes.Internal, "Failed to update role") } return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil @@ -368,35 +387,14 @@ func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdmi return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account") } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { + if _, err := s.userRepository.GetByID(ctx, id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "User not found") } return nil, status.Error(codes.Internal, "Failed to find user") } - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - tables := []struct { - model interface{} - where string - }{ - {&model.AdTemplate{}, "user_id = ?"}, - {&model.Notification{}, "user_id = ?"}, - {&model.Domain{}, "user_id = ?"}, - {&model.WalletTransaction{}, "user_id = ?"}, - {&model.PlanSubscription{}, "user_id = ?"}, - {&model.UserPreference{}, "user_id = ?"}, - {&model.Video{}, "user_id = ?"}, - {&model.Payment{}, "user_id = ?"}, - } - for _, item := range tables { - if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil { - return err - } - } - return tx.Where("id = ?", id).Delete(&model.User{}).Error - }) + err = s.accountRepository.DeleteUserAccount(ctx, id) if err != nil { return nil, status.Error(codes.Internal, "Failed to delete user") } @@ -409,30 +407,12 @@ func (s *appServices) ListAdminVideos(ctx context.Context, req *appv1.ListAdminV } page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - limitInt := int(limit) search := strings.TrimSpace(req.GetSearch()) userID := strings.TrimSpace(req.GetUserId()) statusFilter := strings.TrimSpace(req.GetStatus()) - db := s.db.WithContext(ctx).Model(&model.Video{}) - if search != "" { - like := "%" + search + "%" - db = db.Where("title ILIKE ?", like) - } - if userID != "" { - db = db.Where("user_id = ?", userID) - } - if statusFilter != "" && !strings.EqualFold(statusFilter, "all") { - db = db.Where("status = ?", normalizeVideoStatusValue(statusFilter)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - return nil, status.Error(codes.Internal, "Failed to list videos") - } - - var videos []model.Video - if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil { + videos, total, err := s.videoRepository.ListForAdmin(ctx, search, userID, normalizeVideoStatusValue(statusFilter), offset, int(limit)) + if err != nil { return nil, status.Error(codes.Internal, "Failed to list videos") } @@ -457,15 +437,15 @@ func (s *appServices) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVide return nil, status.Error(codes.NotFound, "Video not found") } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { + video, err := s.videoRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Video not found") } return nil, status.Error(codes.Internal, "Failed to get video") } - payload, err := s.buildAdminVideo(ctx, &video) + payload, err := s.buildAdminVideo(ctx, video) if err != nil { return nil, status.Error(codes.Internal, "Failed to get video") } @@ -476,7 +456,7 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm if _, err := s.requireAdmin(ctx); err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -490,7 +470,7 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0") } - created, err := s.videoService.CreateVideo(ctx, CreateVideoInput{ + created, err := s.videoWorkflowService.CreateVideo(ctx, CreateVideoInput{ UserID: userID, Title: title, Description: req.Description, @@ -538,16 +518,16 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0") } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { + video, err := s.videoRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Video not found") } return nil, status.Error(codes.Internal, "Failed to update video") } - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + user, err := s.userRepository.GetByID(ctx, userID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "User not found") } @@ -570,35 +550,15 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm video.ProcessingStatus = model.StringPtr(processingStatus) video.StorageType = model.StringPtr(detectStorageType(videoURL)) - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Save(&video).Error; err != nil { - return err - } - if oldUserID == user.ID { - delta := video.Size - oldSize - if delta != 0 { - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil { - return err - } - } - } else { - if err := tx.Model(&model.User{}).Where("id = ?", oldUserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil { - return err - } - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil { - return err - } - } - return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId)) - }) + err = s.videoRepository.UpdateAdminVideo(ctx, video, oldUserID, oldSize, nullableTrimmedString(req.AdTemplateId)) if err != nil { - if strings.Contains(err.Error(), "Ad template not found") { + if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.InvalidArgument, "Ad template not found") } return nil, status.Error(codes.Internal, "Failed to update video") } - payload, err := s.buildAdminVideo(ctx, &video) + payload, err := s.buildAdminVideo(ctx, video) if err != nil { return nil, status.Error(codes.Internal, "Failed to update video") } @@ -614,21 +574,15 @@ func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdm return nil, status.Error(codes.NotFound, "Video not found") } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { + video, err := s.videoRepository.GetByID(ctx, id) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Video not found") } return nil, status.Error(codes.Internal, "Failed to find video") } - err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil { - return err - } - return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error - }) - if err != nil { + if err := s.videoRepository.DeleteByIDWithStorageUpdate(ctx, video.ID, video.UserID, video.Size); err != nil { return nil, status.Error(codes.Internal, "Failed to delete video") } diff --git a/internal/service/service_auth.go b/internal/service/service_auth.go index 8811a3a..9ab7ebb 100644 --- a/internal/service/service_auth.go +++ b/internal/service/service_auth.go @@ -16,18 +16,16 @@ import ( "gorm.io/gorm" appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" - "stream.api/internal/database/query" ) -func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { +func (s *authAppService) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { email := strings.TrimSpace(req.GetEmail()) password := req.GetPassword() if email == "" || password == "" { return nil, status.Error(codes.InvalidArgument, "Email and password are required") } - u := query.User - user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() + user, err := s.userRepository.GetByEmail(ctx, email) if err != nil { return nil, status.Error(codes.Unauthenticated, "Invalid credentials") } @@ -38,13 +36,13 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv return nil, status.Error(codes.Unauthenticated, "Invalid credentials") } - payload, err := buildUserPayload(ctx, s.db, user) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } return &appv1.LoginResponse{User: toProtoUser(payload)}, nil } -func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) { +func (s *authAppService) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) { email := strings.TrimSpace(req.GetEmail()) username := strings.TrimSpace(req.GetUsername()) password := req.GetPassword() @@ -53,8 +51,7 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) return nil, status.Error(codes.InvalidArgument, "Username, email and password are required") } - u := query.User - count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count() + count, err := s.userRepository.CountByEmail(ctx, email) if err != nil { s.logger.Error("Failed to check existing user", "error", err) return nil, status.Error(codes.Internal, "Failed to register") @@ -85,21 +82,21 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true), } - if err := u.WithContext(ctx).Create(newUser); err != nil { + if err := s.userRepository.Create(ctx, newUser); err != nil { s.logger.Error("Failed to create user", "error", err) return nil, status.Error(codes.Internal, "Failed to register") } - payload, err := buildUserPayload(ctx, s.db, newUser) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, newUser) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil } -func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) { +func (s *authAppService) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) { return messageResponse("Logged out"), nil } -func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) { +func (s *authAppService) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -122,22 +119,19 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw if err != nil { return nil, status.Error(codes.Internal, "Failed to change password") } - if _, err := query.User.WithContext(ctx). - Where(query.User.ID.Eq(result.UserID)). - Update(query.User.Password, string(newHash)); err != nil { + if err := s.userRepository.UpdatePassword(ctx, result.UserID, string(newHash)); err != nil { s.logger.Error("Failed to change password", "error", err) return nil, status.Error(codes.Internal, "Failed to change password") } return messageResponse("Password changed successfully"), nil } -func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) { +func (s *authAppService) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) { email := strings.TrimSpace(req.GetEmail()) if email == "" { return nil, status.Error(codes.InvalidArgument, "Email is required") } - u := query.User - user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() + user, err := s.userRepository.GetByEmail(ctx, email) if err != nil { return messageResponse("If email exists, a reset link has been sent"), nil } @@ -151,7 +145,7 @@ func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPassw s.logger.Info("Generated password reset token", "email", email, "token", tokenID) return messageResponse("If email exists, a reset link has been sent"), nil } -func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) { +func (s *authAppService) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) { resetToken := strings.TrimSpace(req.GetToken()) newPassword := req.GetNewPassword() if resetToken == "" || newPassword == "" { @@ -168,9 +162,7 @@ func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswor return nil, status.Error(codes.Internal, "Internal error") } - if _, err := query.User.WithContext(ctx). - Where(query.User.ID.Eq(userID)). - Update(query.User.Password, string(hashedPassword)); err != nil { + if err := s.userRepository.UpdatePassword(ctx, userID, string(hashedPassword)); err != nil { s.logger.Error("Failed to update password", "error", err) return nil, status.Error(codes.Internal, "Failed to update password") } @@ -178,7 +170,7 @@ func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswor _ = s.cache.Del(ctx, "reset_pw:"+resetToken) return messageResponse("Password reset successfully"), nil } -func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) { +func (s *authAppService) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) { if err := s.authenticator.RequireInternalCall(ctx); err != nil { return nil, err } @@ -200,7 +192,7 @@ func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleL loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline) return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil } -func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) { +func (s *authAppService) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) { if err := s.authenticator.RequireInternalCall(ctx); err != nil { return nil, err } @@ -249,8 +241,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple return nil, status.Error(codes.InvalidArgument, "missing_email") } - u := query.User - user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First() + user, err := s.userRepository.GetByEmail(ctx, email) if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { s.logger.Error("Failed to load Google user", "error", err) @@ -272,7 +263,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple ReferredByUserID: referrerID, ReferralEligible: model.BoolPtr(true), } - if err := u.WithContext(ctx).Create(user); err != nil { + if err := s.userRepository.Create(ctx, user); err != nil { s.logger.Error("Failed to create Google user", "error", err) return nil, status.Error(codes.Internal, "create_user_failed") } @@ -288,11 +279,11 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple updates["username"] = googleUser.Name } if len(updates) > 0 { - if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { + if err := s.userRepository.UpdateFieldsByID(ctx, user.ID, updates); err != nil { s.logger.Error("Failed to update Google user", "error", err) return nil, status.Error(codes.Internal, "update_user_failed") } - user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First() + user, err = s.userRepository.GetByID(ctx, user.ID) if err != nil { s.logger.Error("Failed to reload Google user", "error", err) return nil, status.Error(codes.Internal, "reload_user_failed") @@ -300,7 +291,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple } } - payload, err := buildUserPayload(ctx, s.db, user) + payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user) if err != nil { return nil, status.Error(codes.Internal, "Failed to build user payload") } diff --git a/internal/service/service_core.go b/internal/service/service_core.go index 38d2dad..05a0e53 100644 --- a/internal/service/service_core.go +++ b/internal/service/service_core.go @@ -11,6 +11,7 @@ import ( "stream.api/internal/config" "stream.api/internal/database/model" "stream.api/internal/middleware" + "stream.api/internal/repository" "stream.api/pkg/logger" "stream.api/pkg/storage" ) @@ -55,6 +56,18 @@ type Services struct { appv1.AdminServer } +type authAppService struct{ *appServices } +type accountAppService struct{ *appServices } +type usageAppService struct{ *appServices } +type notificationsAppService struct{ *appServices } +type domainsAppService struct{ *appServices } +type adTemplatesAppService struct{ *appServices } +type playerConfigsAppService struct{ *appServices } +type plansAppService struct{ *appServices } +type paymentsAppService struct{ *appServices } +type videosAppService struct{ *appServices } +type adminAppService struct{ *appServices } + type appServices struct { appv1.UnimplementedAuthServer appv1.UnimplementedAccountServer @@ -68,17 +81,29 @@ type appServices struct { appv1.UnimplementedVideosServer appv1.UnimplementedAdminServer - db *gorm.DB - logger logger.Logger - authenticator *middleware.Authenticator - cache *redis.RedisAdapter - storageProvider storage.Provider - videoService *Service - agentRuntime AgentRuntime - googleOauth *oauth2.Config - googleStateTTL time.Duration - googleUserInfoURL string - frontendBaseURL string + db *gorm.DB + logger logger.Logger + authenticator *middleware.Authenticator + cache *redis.RedisAdapter + storageProvider storage.Provider + videoWorkflowService VideoWorkflow + videoRepository VideoRepository + userRepository UserRepository + preferenceRepository UserPreferenceRepository + billingRepository BillingRepository + planRepository PlanRepository + paymentRepository PaymentRepository + accountRepository AccountRepository + notificationRepo NotificationRepository + domainRepository DomainRepository + adTemplateRepository AdTemplateRepository + playerConfigRepo PlayerConfigRepository + agentRuntime AgentRuntime + googleOauth *oauth2.Config + googleStateTTL time.Duration + googleUserInfoURL string + frontendBaseURL string + jobRepository JobRepository } type paymentInvoiceDetails struct { @@ -116,7 +141,7 @@ type apiErrorBody struct { Data any `json:"data,omitempty"` } -func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *Service, agentRuntime AgentRuntime) *Services { +func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *config.Config, videoWorkflowService VideoWorkflow, agentRuntime AgentRuntime) *Services { var storageProvider storage.Provider if cfg != nil { provider, err := storage.NewS3Provider(cfg) @@ -151,29 +176,41 @@ func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *confi } service := &appServices{ - db: db, - logger: l, - authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker), - cache: c, - storageProvider: storageProvider, - videoService: videoService, - agentRuntime: agentRuntime, - googleOauth: googleOauth, - googleStateTTL: googleStateTTL, - googleUserInfoURL: defaultGoogleUserInfoURL, - frontendBaseURL: frontendBaseURL, + db: db, + logger: l, + authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker), + cache: c, + storageProvider: storageProvider, + videoWorkflowService: videoWorkflowService, + videoRepository: repository.NewVideoRepository(db), + userRepository: repository.NewUserRepository(db), + preferenceRepository: repository.NewUserPreferenceRepository(db), + billingRepository: repository.NewBillingRepository(db), + planRepository: repository.NewPlanRepository(db), + paymentRepository: repository.NewPaymentRepository(db), + accountRepository: repository.NewAccountRepository(db), + notificationRepo: repository.NewNotificationRepository(db), + domainRepository: repository.NewDomainRepository(db), + adTemplateRepository: repository.NewAdTemplateRepository(db), + playerConfigRepo: repository.NewPlayerConfigRepository(db), + jobRepository: repository.NewJobRepository(db), + agentRuntime: agentRuntime, + googleOauth: googleOauth, + googleStateTTL: googleStateTTL, + googleUserInfoURL: defaultGoogleUserInfoURL, + frontendBaseURL: frontendBaseURL, } return &Services{ - AuthServer: service, - AccountServer: service, - UsageServer: service, - NotificationsServer: service, - DomainsServer: service, - AdTemplatesServer: service, - PlayerConfigsServer: service, - PlansServer: service, - PaymentsServer: service, - VideosServer: service, - AdminServer: service, + AuthServer: &authAppService{appServices: service}, + AccountServer: &accountAppService{appServices: service}, + UsageServer: &usageAppService{appServices: service}, + NotificationsServer: ¬ificationsAppService{appServices: service}, + DomainsServer: &domainsAppService{appServices: service}, + AdTemplatesServer: &adTemplatesAppService{appServices: service}, + PlayerConfigsServer: &playerConfigsAppService{appServices: service}, + PlansServer: &plansAppService{appServices: service}, + PaymentsServer: &paymentsAppService{appServices: service}, + VideosServer: &videosAppService{appServices: service}, + AdminServer: &adminAppService{appServices: service}, } } diff --git a/internal/service/service_health.go b/internal/service/service_health.go index 59737b9..61405b1 100644 --- a/internal/service/service_health.go +++ b/internal/service/service_health.go @@ -82,10 +82,6 @@ func (s *HealthService) checkDatabase(ctx context.Context) ComponentHealth { if err := sqlDB.PingContext(ctx); err != nil { return ComponentHealth{Status: HealthStatusUnhealthy, Message: fmt.Sprintf("database ping failed: %v", err), Latency: time.Since(start).String(), CheckedAt: time.Now()} } - var result int - if err := s.db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error; err != nil { - return ComponentHealth{Status: HealthStatusUnhealthy, Message: fmt.Sprintf("database query failed: %v", err), Latency: time.Since(start).String(), CheckedAt: time.Now()} - } return ComponentHealth{Status: HealthStatusHealthy, Latency: time.Since(start).String(), CheckedAt: time.Now()} } diff --git a/internal/service/service_job.go b/internal/service/service_job.go index 80138d2..56f3f19 100644 --- a/internal/service/service_job.go +++ b/internal/service/service_job.go @@ -11,9 +11,10 @@ import ( "strings" "time" + "gorm.io/gorm" "stream.api/internal/database/model" - "stream.api/internal/database/query" "stream.api/internal/dto" + "stream.api/internal/repository" ) type JobQueue interface { @@ -33,12 +34,17 @@ type LogPubSub interface { } type JobService struct { - queue JobQueue - pubsub LogPubSub + queue JobQueue + pubsub LogPubSub + jobRepository JobRepository } -func NewJobService(queue JobQueue, pubsub LogPubSub) *JobService { - return &JobService{queue: queue, pubsub: pubsub} +func NewJobService(db *gorm.DB, queue JobQueue, pubsub LogPubSub) *JobService { + return &JobService{ + queue: queue, + pubsub: pubsub, + jobRepository: repository.NewJobRepository(db), + } } var ErrInvalidJobCursor = errors.New("invalid job cursor") @@ -127,28 +133,16 @@ func buildJobListCursor(job *model.Job, agentID string) (string, error) { }) } -func listJobsByOffset(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { +func listJobsByOffset(ctx context.Context, jobRepository JobRepository, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { if offset < 0 { offset = 0 } limit = normalizeJobPageSize(limit) - q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) - if agentID != "" { - agentNumeric, err := strconv.ParseInt(agentID, 10, 64) - if err != nil { - return &dto.PaginatedJobs{Jobs: []*model.Job{}, Total: 0, Offset: offset, Limit: limit, PageSize: limit, HasMore: false}, nil - } - q = q.Where(query.Job.AgentID.Eq(agentNumeric)) - } - jobs, total, err := q.FindByPage(offset, limit) + jobs, total, err := jobRepository.ListByOffset(ctx, agentID, offset, limit) if err != nil { return nil, err } - items := make([]*model.Job, 0, len(jobs)) - for _, job := range jobs { - items = append(items, job) - } - return &dto.PaginatedJobs{Jobs: items, Total: total, Offset: offset, Limit: limit, PageSize: limit, HasMore: offset+len(items) < int(total)}, nil + return &dto.PaginatedJobs{Jobs: jobs, Total: total, Offset: offset, Limit: limit, PageSize: limit, HasMore: offset+len(jobs) < int(total)}, nil } func (s *JobService) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) { @@ -165,10 +159,10 @@ func (s *JobService) CreateJob(ctx context.Context, userID string, videoID strin CreatedAt: timePtr(now), UpdatedAt: timePtr(now), } - if err := query.Job.WithContext(ctx).Create(job); err != nil { + if err := s.jobRepository.Create(ctx, job); err != nil { return nil, err } - if err := syncVideoStatus(ctx, videoID, dto.JobStatusPending); err != nil { + if err := syncVideoStatus(ctx, s.jobRepository, videoID, dto.JobStatusPending); err != nil { return nil, err } // dtoJob := todtoJob(job) @@ -180,11 +174,11 @@ func (s *JobService) CreateJob(ctx context.Context, userID string, videoID strin } func (s *JobService) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) { - return listJobsByOffset(ctx, "", offset, limit) + return listJobsByOffset(ctx, s.jobRepository, "", offset, limit) } func (s *JobService) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { - return listJobsByOffset(ctx, strings.TrimSpace(agentID), offset, limit) + return listJobsByOffset(ctx, s.jobRepository, strings.TrimSpace(agentID), offset, limit) } func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) { @@ -199,39 +193,19 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso return nil, ErrInvalidJobCursor } - q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) - if agentID != "" { - agentNumeric, err := strconv.ParseInt(agentID, 10, 64) - if err != nil { - return &dto.PaginatedJobs{Jobs: []*model.Job{}, Total: 0, Limit: pageSize, PageSize: pageSize, HasMore: false}, nil - } - q = q.Where(query.Job.AgentID.Eq(agentNumeric)) - } var cursorTime time.Time if decodedCursor != nil { cursorTime = time.Unix(0, decodedCursor.CreatedAtUnixNano).UTC() } - - queryDB := q.UnderlyingDB() + cursorID := "" if decodedCursor != nil { - queryDB = queryDB.Where("(created_at < ?) OR (created_at = ? AND id < ?)", cursorTime, cursorTime, decodedCursor.ID) + cursorID = decodedCursor.ID } - - var jobs []*model.Job - if err := queryDB.Limit(pageSize + 1).Find(&jobs).Error; err != nil { + jobs, hasMore, err := s.jobRepository.ListByCursor(ctx, agentID, cursorTime, cursorID, pageSize) + if err != nil { return nil, err } - hasMore := len(jobs) > pageSize - if hasMore { - jobs = jobs[:pageSize] - } - - items := make([]*model.Job, 0, len(jobs)) - for _, job := range jobs { - items = append(items, job) - } - nextCursor := "" if hasMore && len(jobs) > 0 { nextCursor, err = buildJobListCursor(jobs[len(jobs)-1], agentID) @@ -241,7 +215,7 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso } return &dto.PaginatedJobs{ - Jobs: items, + Jobs: jobs, Total: 0, Limit: pageSize, PageSize: pageSize, @@ -251,7 +225,7 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso } func (s *JobService) GetJob(ctx context.Context, id string) (*model.Job, error) { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(id)).First() + job, err := s.jobRepository.GetByID(ctx, id) if err != nil { return nil, err } @@ -275,25 +249,25 @@ func (s *JobService) SubscribeJobUpdates(ctx context.Context) (<-chan string, er } func (s *JobService) UpdateJobStatus(ctx context.Context, jobID string, status dto.JobStatus) error { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return err } now := time.Now() job.Status = strPtr(string(status)) job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return err } cfg := parseJobConfig(job.Config) - if err := syncVideoStatus(ctx, cfg.VideoID, status); err != nil { + if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, status); err != nil { return err } return s.pubsub.PublishJobUpdate(ctx, jobID, string(status), cfg.VideoID) } func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string) error { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return err } @@ -306,18 +280,18 @@ func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string job.AgentID = &agentNumeric job.Status = &status job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return err } cfg := parseJobConfig(job.Config) - if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusRunning); err != nil { + if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusRunning); err != nil { return err } return s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) } func (s *JobService) CancelJob(ctx context.Context, jobID string) error { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return fmt.Errorf("job not found: %w", err) } @@ -334,11 +308,11 @@ func (s *JobService) CancelJob(ctx context.Context, jobID string) error { job.Cancelled = &cancelled job.Status = &status job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return err } cfg := parseJobConfig(job.Config) - if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusCancelled); err != nil { + if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusCancelled); err != nil { return err } _ = s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) @@ -349,7 +323,7 @@ func (s *JobService) CancelJob(ctx context.Context, jobID string) error { } func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, error) { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return nil, fmt.Errorf("job not found: %w", err) } @@ -381,11 +355,11 @@ func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, er job.Progress = &progress job.AgentID = nil job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return nil, err } cfg := parseJobConfig(job.Config) - if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusPending); err != nil { + if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusPending); err != nil { return nil, err } // dtoJob := todtoJob(job) @@ -397,14 +371,14 @@ func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, er } func (s *JobService) UpdateJobProgress(ctx context.Context, jobID string, progress float64) error { - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return err } now := time.Now() job.Progress = float64Ptr(progress) job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return err } return s.pubsub.Publish(ctx, jobID, "", progress) @@ -421,7 +395,7 @@ func (s *JobService) ProcessLog(ctx context.Context, jobID string, logData []byt progress = float64(us) / 1000000.0 } } - job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() + job, err := s.jobRepository.GetByID(ctx, jobID) if err != nil { return err } @@ -443,13 +417,13 @@ func (s *JobService) ProcessLog(ctx context.Context, jobID string, logData []byt job.Progress = float64Ptr(progress) } job.UpdatedAt = &now - if err := query.Job.WithContext(ctx).Save(job); err != nil { + if err := s.jobRepository.Save(ctx, job); err != nil { return err } return s.pubsub.Publish(ctx, jobID, line, progress) } -func syncVideoStatus(ctx context.Context, videoID string, status dto.JobStatus) error { +func syncVideoStatus(ctx context.Context, jobRepository JobRepository, videoID string, status dto.JobStatus) error { videoID = strings.TrimSpace(videoID) if videoID == "" { return nil @@ -466,10 +440,7 @@ func syncVideoStatus(ctx context.Context, videoID string, status dto.JobStatus) processingStatus = "FAILED" } - _, err := query.Video.WithContext(ctx). - Where(query.Video.ID.Eq(videoID)). - Updates(map[string]any{"status": statusValue, "processing_status": processingStatus}) - return err + return jobRepository.UpdateVideoStatus(ctx, videoID, statusValue, processingStatus) } func (s *JobService) PublishSystemResources(ctx context.Context, agentID string, data []byte) error { diff --git a/internal/service/service_payments.go b/internal/service/service_payments.go index 92475d3..52a4669 100644 --- a/internal/service/service_payments.go +++ b/internal/service/service_payments.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "strings" - "time" "github.com/google/uuid" "google.golang.org/grpc/codes" @@ -13,10 +12,9 @@ import ( "gorm.io/gorm" appv1 "stream.api/internal/api/proto/app/v1" "stream.api/internal/database/model" - "stream.api/internal/database/query" ) -func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) { +func (s *paymentsAppService) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -63,7 +61,7 @@ func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymen Message: "Payment completed successfully", }, nil } -func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) { +func (s *paymentsAppService) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -71,71 +69,8 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPay page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) - type paymentHistoryRow struct { - ID string `gorm:"column:id"` - Amount float64 `gorm:"column:amount"` - Currency *string `gorm:"column:currency"` - Status *string `gorm:"column:status"` - PlanID *string `gorm:"column:plan_id"` - PlanName *string `gorm:"column:plan_name"` - InvoiceID string `gorm:"column:invoice_id"` - Kind string `gorm:"column:kind"` - TermMonths *int32 `gorm:"column:term_months"` - PaymentMethod *string `gorm:"column:payment_method"` - ExpiresAt *time.Time `gorm:"column:expires_at"` - CreatedAt *time.Time `gorm:"column:created_at"` - } - - baseQuery := ` - WITH history AS ( - SELECT - p.id AS id, - p.amount AS amount, - p.currency AS currency, - p.status AS status, - p.plan_id AS plan_id, - pl.name AS plan_name, - p.id AS invoice_id, - ? AS kind, - ps.term_months AS term_months, - ps.payment_method AS payment_method, - ps.expires_at AS expires_at, - p.created_at AS created_at - FROM payment AS p - LEFT JOIN plan AS pl ON pl.id = p.plan_id - LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id - WHERE p.user_id = ? - UNION ALL - SELECT - wt.id AS id, - wt.amount AS amount, - wt.currency AS currency, - 'SUCCESS' AS status, - NULL AS plan_id, - NULL AS plan_name, - wt.id AS invoice_id, - ? AS kind, - NULL AS term_months, - NULL AS payment_method, - NULL AS expires_at, - wt.created_at AS created_at - FROM wallet_transactions AS wt - WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL - ) - ` - - var total int64 - if err := s.db.WithContext(ctx). - Raw(baseQuery+`SELECT COUNT(*) FROM history`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup). - Scan(&total).Error; err != nil { - s.logger.Error("Failed to count payment history", "error", err) - return nil, status.Error(codes.Internal, "Failed to fetch payment history") - } - - var rows []paymentHistoryRow - if err := s.db.WithContext(ctx). - Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup, limit, offset). - Scan(&rows).Error; err != nil { + rows, total, err := s.paymentRepository.ListHistoryByUser(ctx, result.UserID, paymentKindSubscription, paymentKindWalletTopup, walletTransactionTypeTopup, limit, offset) + if err != nil { s.logger.Error("Failed to fetch payment history", "error", err) return nil, status.Error(codes.Internal, "Failed to fetch payment history") } @@ -170,7 +105,7 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPay }, nil } -func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) { +func (s *paymentsAppService) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -202,23 +137,12 @@ func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletReq })), } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil { - return err - } - if err := tx.Create(transaction).Error; err != nil { - return err - } - if err := tx.Create(notification).Error; err != nil { - return err - } - return nil - }); err != nil { + if err := s.paymentRepository.CreateWalletTopupAndNotification(ctx, result.UserID, transaction, notification); err != nil { s.logger.Error("Failed to top up wallet", "error", err) return nil, status.Error(codes.Internal, "Failed to top up wallet") } - balance, err := model.GetWalletBalance(ctx, s.db, result.UserID) + balance, err := s.billingRepository.GetWalletBalance(ctx, result.UserID) if err != nil { s.logger.Error("Failed to calculate wallet balance", "error", err) return nil, status.Error(codes.Internal, "Failed to top up wallet") @@ -230,7 +154,7 @@ func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletReq InvoiceId: buildInvoiceID(transaction.ID), }, nil } -func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) { +func (s *paymentsAppService) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -241,9 +165,7 @@ func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadIn return nil, status.Error(codes.NotFound, "Invoice not found") } - paymentRecord, err := query.Payment.WithContext(ctx). - Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)). - First() + paymentRecord, err := s.paymentRepository.GetByIDAndUser(ctx, id, result.UserID) if err == nil { invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord) if buildErr != nil { @@ -261,14 +183,12 @@ func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadIn return nil, status.Error(codes.Internal, "Failed to download invoice") } - var topup model.WalletTransaction - if err := s.db.WithContext(ctx). - Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup). - First(&topup).Error; err == nil { + topup, err := s.paymentRepository.GetStandaloneTopupByIDAndUser(ctx, id, result.UserID, walletTransactionTypeTopup) + if err == nil { return &appv1.DownloadInvoiceResponse{ Filename: buildInvoiceFilename(topup.ID), ContentType: "text/plain; charset=utf-8", - Content: buildTopupInvoice(&topup), + Content: buildTopupInvoice(topup), }, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { s.logger.Error("Failed to load topup invoice", "error", err) diff --git a/internal/service/service_policy_helpers.go b/internal/service/service_policy_helpers.go new file mode 100644 index 0000000..4b90f22 --- /dev/null +++ b/internal/service/service_policy_helpers.go @@ -0,0 +1,52 @@ +package service + +import ( + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "stream.api/internal/database/model" +) + +func ensurePaidPlan(user *model.User) error { + if user == nil { + return status.Error(codes.Unauthenticated, "Unauthorized") + } + if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" { + return status.Error(codes.PermissionDenied, adTemplateUpgradeRequiredMessage) + } + return nil +} + +func playerConfigActionAllowed(user *model.User, configCount int64, action string) error { + if user == nil { + return status.Error(codes.Unauthenticated, "Unauthorized") + } + if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" { + return nil + } + + switch action { + case "create": + if configCount > 0 { + return status.Error(codes.FailedPrecondition, playerConfigFreePlanLimitMessage) + } + return nil + case "delete": + return nil + case "update", "set-default", "toggle-active": + if configCount > 1 { + return status.Error(codes.FailedPrecondition, playerConfigFreePlanReconciliationMessage) + } + return nil + default: + return nil + } +} + +func safeRole(role *string) string { + if role == nil || strings.TrimSpace(*role) == "" { + return "USER" + } + return *role +} diff --git a/internal/service/service_render.go b/internal/service/service_render.go deleted file mode 100644 index dd2555b..0000000 --- a/internal/service/service_render.go +++ /dev/null @@ -1,225 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "errors" - "strings" - - "github.com/google/uuid" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/internal/dto" -) - -type AgentRuntime interface { - ListAgentsWithStats() []*dto.AgentWithStats - SendCommand(agentID string, cmd string) bool -} - -var ( - ErrUserNotFound = errors.New("user not found") - ErrAdTemplateNotFound = errors.New("ad template not found") - ErrJobServiceUnavailable = errors.New("job service is unavailable") -) - -type Service struct { - db *gorm.DB - jobService *JobService -} - -type CreateVideoInput struct { - UserID string - Title string - Description *string - URL string - Size int64 - Duration int32 - Format string - AdTemplateID *string -} - -type CreateVideoResult struct { - Video *model.Video - Job model.Job -} - -func NewService(db *gorm.DB, jobService *JobService) *Service { - return &Service{db: db, jobService: jobService} -} - -func (s *Service) JobService() *JobService { - if s == nil { - return nil - } - return s.jobService -} - -func (s *Service) CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error) { - if s == nil || s.db == nil { - return nil, gorm.ErrInvalidDB - } - - userID := strings.TrimSpace(input.UserID) - if userID == "" { - return nil, ErrUserNotFound - } - - var user model.User - if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrUserNotFound - } - return nil, err - } - - title := strings.TrimSpace(input.Title) - videoURL := strings.TrimSpace(input.URL) - format := strings.TrimSpace(input.Format) - statusValue := "processing" - processingStatus := "PENDING" - storageType := detectStorageType(videoURL) - - video := &model.Video{ - ID: uuid.NewString(), - UserID: user.ID, - Name: title, - Title: title, - Description: nullableTrimmedString(input.Description), - URL: videoURL, - Size: input.Size, - Duration: input.Duration, - Format: format, - Status: model.StringPtr(statusValue), - ProcessingStatus: model.StringPtr(processingStatus), - StorageType: model.StringPtr(storageType), - } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(video).Error; err != nil { - return err - } - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil { - return err - } - return saveVideoAdConfig(ctx, tx, video, user.ID, input.AdTemplateID) - }); err != nil { - return nil, err - } - - if s.jobService == nil { - _ = markVideoJobFailed(ctx, s.db, video.ID) - return nil, ErrJobServiceUnavailable - } - - jobPayload, err := buildJobPayload(video.ID, user.ID, videoURL, format) - if err != nil { - _ = markVideoJobFailed(ctx, s.db, video.ID) - return nil, err - } - - job, err := s.jobService.CreateJob(ctx, user.ID, video.ID, title, jobPayload, 0, 0) - if err != nil { - _ = markVideoJobFailed(ctx, s.db, video.ID) - return nil, err - } - - return &CreateVideoResult{Video: video, Job: *job}, nil -} - -func (s *Service) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.ListJobs(ctx, offset, limit) -} - -func (s *Service) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.ListJobsByAgent(ctx, agentID, offset, limit) -} - -func (s *Service) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.ListJobsByCursor(ctx, agentID, cursor, pageSize) -} - -func (s *Service) GetJob(ctx context.Context, id string) (*model.Job, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.GetJob(ctx, id) -} - -func (s *Service) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.CreateJob(ctx, userID, videoID, name, config, priority, timeLimit) -} - -func (s *Service) CancelJob(ctx context.Context, id string) error { - if s == nil || s.jobService == nil { - return ErrJobServiceUnavailable - } - return s.jobService.CancelJob(ctx, id) -} - -func (s *Service) RetryJob(ctx context.Context, id string) (*model.Job, error) { - if s == nil || s.jobService == nil { - return nil, ErrJobServiceUnavailable - } - return s.jobService.RetryJob(ctx, id) -} - -func buildJobPayload(videoID, userID, videoURL, format string) ([]byte, error) { - return json.Marshal(map[string]any{ - "video_id": videoID, - "user_id": userID, - "input_url": videoURL, - "source_url": videoURL, - "format": format, - }) -} - -func saveVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error { - if video == nil || adTemplateID == nil { - return nil - } - - trimmed := strings.TrimSpace(*adTemplateID) - if trimmed == "" { - if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil { - return err - } - video.AdID = nil - return nil - } - - var template model.AdTemplate - if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrAdTemplateNotFound - } - return err - } - - if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil { - return err - } - video.AdID = &template.ID - return nil -} - -func markVideoJobFailed(ctx context.Context, db *gorm.DB, videoID string) error { - if db == nil { - return nil - } - return db.WithContext(ctx). - Model(&model.Video{}). - Where("id = ?", strings.TrimSpace(videoID)). - Updates(map[string]any{"status": "failed", "processing_status": "FAILED"}).Error -} diff --git a/internal/service/service_user_features.go b/internal/service/service_user_features.go index 85a7c4e..542e644 100644 --- a/internal/service/service_user_features.go +++ b/internal/service/service_user_features.go @@ -13,17 +13,14 @@ import ( "stream.api/internal/database/model" ) -func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) { +func (s *notificationsAppService) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - var rows []model.Notification - if err := s.db.WithContext(ctx). - Where("user_id = ?", result.UserID). - Order("created_at DESC"). - Find(&rows).Error; err != nil { + rows, err := s.notificationRepo.ListByUser(ctx, result.UserID) + if err != nil { s.logger.Error("Failed to list notifications", "error", err) return nil, status.Error(codes.Internal, "Failed to load notifications") } @@ -35,7 +32,7 @@ func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotifi return &appv1.ListNotificationsResponse{Notifications: items}, nil } -func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) { +func (s *notificationsAppService) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -46,37 +43,31 @@ func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkN return nil, status.Error(codes.NotFound, "Notification not found") } - res := s.db.WithContext(ctx). - Model(&model.Notification{}). - Where("id = ? AND user_id = ?", id, result.UserID). - Update("is_read", true) - if res.Error != nil { - s.logger.Error("Failed to update notification", "error", res.Error) + rowsAffected, err := s.notificationRepo.MarkReadByIDAndUser(ctx, id, result.UserID) + if err != nil { + s.logger.Error("Failed to update notification", "error", err) return nil, status.Error(codes.Internal, "Failed to update notification") } - if res.RowsAffected == 0 { + if rowsAffected == 0 { return nil, status.Error(codes.NotFound, "Notification not found") } return messageResponse("Notification updated"), nil } -func (s *appServices) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) { +func (s *notificationsAppService) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - if err := s.db.WithContext(ctx). - Model(&model.Notification{}). - Where("user_id = ? AND is_read = ?", result.UserID, false). - Update("is_read", true).Error; err != nil { + if err := s.notificationRepo.MarkAllReadByUser(ctx, result.UserID); err != nil { s.logger.Error("Failed to mark all notifications as read", "error", err) return nil, status.Error(codes.Internal, "Failed to update notifications") } return messageResponse("All notifications marked as read"), nil } -func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) { +func (s *notificationsAppService) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -87,43 +78,38 @@ func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteN return nil, status.Error(codes.NotFound, "Notification not found") } - res := s.db.WithContext(ctx). - Where("id = ? AND user_id = ?", id, result.UserID). - Delete(&model.Notification{}) - if res.Error != nil { - s.logger.Error("Failed to delete notification", "error", res.Error) + rowsAffected, err := s.notificationRepo.DeleteByIDAndUser(ctx, id, result.UserID) + if err != nil { + s.logger.Error("Failed to delete notification", "error", err) return nil, status.Error(codes.Internal, "Failed to delete notification") } - if res.RowsAffected == 0 { + if rowsAffected == 0 { return nil, status.Error(codes.NotFound, "Notification not found") } return messageResponse("Notification deleted"), nil } -func (s *appServices) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) { +func (s *notificationsAppService) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - if err := s.db.WithContext(ctx).Where("user_id = ?", result.UserID).Delete(&model.Notification{}).Error; err != nil { + if err := s.notificationRepo.DeleteAllByUser(ctx, result.UserID); err != nil { s.logger.Error("Failed to clear notifications", "error", err) return nil, status.Error(codes.Internal, "Failed to clear notifications") } return messageResponse("All notifications deleted"), nil } -func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) { +func (s *domainsAppService) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - var rows []model.Domain - if err := s.db.WithContext(ctx). - Where("user_id = ?", result.UserID). - Order("created_at DESC"). - Find(&rows).Error; err != nil { + rows, err := s.domainRepository.ListByUser(ctx, result.UserID) + if err != nil { s.logger.Error("Failed to list domains", "error", err) return nil, status.Error(codes.Internal, "Failed to load domains") } @@ -136,7 +122,7 @@ func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsReque return &appv1.ListDomainsResponse{Domains: items}, nil } -func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) { +func (s *domainsAppService) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -147,11 +133,8 @@ func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainR return nil, status.Error(codes.InvalidArgument, "Invalid domain") } - var count int64 - if err := s.db.WithContext(ctx). - Model(&model.Domain{}). - Where("user_id = ? AND name = ?", result.UserID, name). - Count(&count).Error; err != nil { + count, err := s.domainRepository.CountByUserAndName(ctx, result.UserID, name) + if err != nil { s.logger.Error("Failed to validate domain", "error", err) return nil, status.Error(codes.Internal, "Failed to create domain") } @@ -164,14 +147,14 @@ func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainR UserID: result.UserID, Name: name, } - if err := s.db.WithContext(ctx).Create(item).Error; err != nil { + if err := s.domainRepository.Create(ctx, item); err != nil { s.logger.Error("Failed to create domain", "error", err) return nil, status.Error(codes.Internal, "Failed to create domain") } return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil } -func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) { +func (s *domainsAppService) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -182,31 +165,25 @@ func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainR return nil, status.Error(codes.NotFound, "Domain not found") } - res := s.db.WithContext(ctx). - Where("id = ? AND user_id = ?", id, result.UserID). - Delete(&model.Domain{}) - if res.Error != nil { - s.logger.Error("Failed to delete domain", "error", res.Error) + rowsAffected, err := s.domainRepository.DeleteByIDAndUser(ctx, id, result.UserID) + if err != nil { + s.logger.Error("Failed to delete domain", "error", err) return nil, status.Error(codes.Internal, "Failed to delete domain") } - if res.RowsAffected == 0 { + if rowsAffected == 0 { return nil, status.Error(codes.NotFound, "Domain not found") } return messageResponse("Domain deleted"), nil } -func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) { +func (s *adTemplatesAppService) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - var items []model.AdTemplate - if err := s.db.WithContext(ctx). - Where("user_id = ?", result.UserID). - Order("is_default DESC"). - Order("created_at DESC"). - Find(&items).Error; err != nil { + items, err := s.adTemplateRepository.ListByUser(ctx, result.UserID) + if err != nil { s.logger.Error("Failed to list ad templates", "error", err) return nil, status.Error(codes.Internal, "Failed to load ad templates") } @@ -219,7 +196,7 @@ func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTempla return &appv1.ListAdTemplatesResponse{Templates: payload}, nil } -func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) { +func (s *adTemplatesAppService) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -254,21 +231,14 @@ func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdT item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetDefaultTemplates(tx, result.UserID, ""); err != nil { - return err - } - } - return tx.Create(item).Error - }); err != nil { + if err := s.adTemplateRepository.CreateWithDefault(ctx, result.UserID, item); err != nil { s.logger.Error("Failed to create ad template", "error", err) return nil, status.Error(codes.Internal, "Failed to save ad template") } return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil } -func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) { +func (s *adTemplatesAppService) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -293,8 +263,8 @@ func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdT return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates") } - var item model.AdTemplate - if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil { + item, err := s.adTemplateRepository.GetByIDAndUser(ctx, id, result.UserID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Ad template not found") } @@ -317,21 +287,14 @@ func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdT item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetDefaultTemplates(tx, result.UserID, item.ID); err != nil { - return err - } - } - return tx.Save(&item).Error - }); err != nil { + if err := s.adTemplateRepository.SaveWithDefault(ctx, result.UserID, item); err != nil { s.logger.Error("Failed to update ad template", "error", err) return nil, status.Error(codes.Internal, "Failed to save ad template") } - return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(&item)}, nil + return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil } -func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) { +func (s *adTemplatesAppService) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -345,22 +308,7 @@ func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdT return nil, status.Error(codes.NotFound, "Ad template not found") } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.Video{}). - Where("user_id = ? AND ad_id = ?", result.UserID, id). - Update("ad_id", nil).Error; err != nil { - return err - } - - res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.AdTemplate{}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil - }); err != nil { + if err := s.adTemplateRepository.DeleteByIDAndUserAndClearVideos(ctx, id, result.UserID); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Ad template not found") } @@ -370,13 +318,13 @@ func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdT return messageResponse("Ad template deleted"), nil } -func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) { +func (s *plansAppService) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) { if _, err := s.authenticate(ctx); err != nil { return nil, err } - var plans []model.Plan - if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil { + plans, err := s.planRepository.ListActive(ctx) + if err != nil { s.logger.Error("Failed to fetch plans", "error", err) return nil, status.Error(codes.Internal, "Failed to fetch plans") } @@ -390,18 +338,14 @@ func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) return &appv1.ListPlansResponse{Plans: items}, nil } -func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) { +func (s *playerConfigsAppService) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - var items []model.PlayerConfig - if err := s.db.WithContext(ctx). - Where("user_id = ?", result.UserID). - Order("is_default DESC"). - Order("created_at DESC"). - Find(&items).Error; err != nil { + items, err := s.playerConfigRepo.ListByUser(ctx, result.UserID) + if err != nil { s.logger.Error("Failed to list player configs", "error", err) return nil, status.Error(codes.Internal, "Failed to load player configs") } @@ -415,7 +359,7 @@ func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayer return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil } -func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) { +func (s *playerConfigsAppService) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -447,29 +391,8 @@ func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreateP item.IsDefault = false } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID) - if err != nil { - return err - } - - var configCount int64 - if err := tx.WithContext(ctx). - Model(&model.PlayerConfig{}). - Where("user_id = ?", result.UserID). - Count(&configCount).Error; err != nil { - return err - } - if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil { - return err - } - - if item.IsDefault { - if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil { - return err - } - } - return tx.Create(item).Error + if err := s.playerConfigRepo.CreateManaged(ctx, result.UserID, item, func(lockedUser *model.User, configCount int64) error { + return playerConfigActionAllowed(lockedUser, configCount, "create") }); err != nil { if status.Code(err) != codes.Unknown { return nil, err @@ -481,7 +404,7 @@ func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreateP return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil } -func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) { +func (s *playerConfigsAppService) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -497,25 +420,7 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP return nil, status.Error(codes.InvalidArgument, "Name is required") } - var item model.PlayerConfig - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID) - if err != nil { - return err - } - - var configCount int64 - if err := tx.WithContext(ctx). - Model(&model.PlayerConfig{}). - Where("user_id = ?", result.UserID). - Count(&configCount).Error; err != nil { - return err - } - - if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil { - return err - } - + item, err := s.playerConfigRepo.UpdateManaged(ctx, result.UserID, id, func(item *model.PlayerConfig, lockedUser *model.User, configCount int64) error { action := "update" wasActive := playerConfigIsActive(item.IsActive) if req.IsActive != nil && *req.IsActive != wasActive { @@ -552,14 +457,9 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP if !playerConfigIsActive(item.IsActive) { item.IsDefault = false } - - if item.IsDefault { - if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil { - return err - } - } - return tx.Save(&item).Error - }); err != nil { + return nil + }) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Player config not found") } @@ -570,10 +470,10 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP return nil, status.Error(codes.Internal, "Failed to save player config") } - return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil + return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil } -func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) { +func (s *playerConfigsAppService) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -584,31 +484,8 @@ func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeleteP return nil, status.Error(codes.NotFound, "Player config not found") } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID) - if err != nil { - return err - } - - var configCount int64 - if err := tx.WithContext(ctx). - Model(&model.PlayerConfig{}). - Where("user_id = ?", result.UserID). - Count(&configCount).Error; err != nil { - return err - } - if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil { - return err - } - - res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil + if err := s.playerConfigRepo.DeleteManaged(ctx, result.UserID, id, func(lockedUser *model.User, configCount int64) error { + return playerConfigActionAllowed(lockedUser, configCount, "delete") }); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Player config not found") diff --git a/internal/service/service_videos.go b/internal/service/service_videos.go index 42b5c6d..4bf69b4 100644 --- a/internal/service/service_videos.go +++ b/internal/service/service_videos.go @@ -12,10 +12,9 @@ import ( "google.golang.org/grpc/status" "gorm.io/gorm" appv1 "stream.api/internal/api/proto/app/v1" - "stream.api/internal/database/model" ) -func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) { +func (s *videosAppService) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -39,12 +38,12 @@ func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlR return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil } -func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) { +func (s *videosAppService) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err } - if s.videoService == nil { + if s.videoWorkflowService == nil { return nil, status.Error(codes.Unavailable, "Job service is unavailable") } @@ -58,7 +57,7 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq } description := strings.TrimSpace(req.GetDescription()) - created, err := s.videoService.CreateVideo(ctx, CreateVideoInput{ + created, err := s.videoWorkflowService.CreateVideo(ctx, CreateVideoInput{ UserID: result.UserID, Title: title, Description: &description, @@ -79,7 +78,7 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil } -func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) { +func (s *videosAppService) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -97,24 +96,12 @@ func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosReque limit = 100 } offset := int((page - 1) * limit) - - db := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", result.UserID) - if search := strings.TrimSpace(req.GetSearch()); search != "" { - like := "%" + search + "%" - db = db.Where("title ILIKE ? OR description ILIKE ?", like, like) - } - if st := strings.TrimSpace(req.GetStatus()); st != "" && !strings.EqualFold(st, "all") { - db = db.Where("status = ?", normalizeVideoStatusValue(st)) + if s.videoRepository == nil { + return nil, status.Error(codes.Internal, "Video repository is unavailable") } - var total int64 - if err := db.Count(&total).Error; err != nil { - s.logger.Error("Failed to count videos", "error", err) - return nil, status.Error(codes.Internal, "Failed to fetch videos") - } - - var videos []model.Video - if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil { + videos, total, err := s.videoRepository.ListByUser(ctx, result.UserID, req.GetSearch(), normalizeVideoStatusFilter(req.GetStatus()), offset, int(limit)) + if err != nil { s.logger.Error("Failed to list videos", "error", err) return nil, status.Error(codes.Internal, "Failed to fetch videos") } @@ -131,7 +118,7 @@ func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosReque return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil } -func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) { +func (s *videosAppService) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -142,12 +129,14 @@ func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) return nil, status.Error(codes.NotFound, "Video not found") } - _ = s.db.WithContext(ctx).Model(&model.Video{}). - Where("id = ? AND user_id = ?", id, result.UserID). - UpdateColumn("views", gorm.Expr("views + ?", 1)).Error + if s.videoRepository == nil { + return nil, status.Error(codes.Internal, "Video repository is unavailable") + } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil { + _ = s.videoRepository.IncrementViews(ctx, id, result.UserID) + + video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Video not found") } @@ -155,14 +144,14 @@ func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) return nil, status.Error(codes.Internal, "Failed to fetch video") } - payload, err := s.buildVideo(ctx, &video) + payload, err := s.buildVideo(ctx, video) if err != nil { s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID) return nil, status.Error(codes.Internal, "Failed to fetch video") } return &appv1.GetVideoResponse{Video: payload}, nil } -func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) { +func (s *videosAppService) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -201,32 +190,33 @@ func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoReq return nil, status.Error(codes.InvalidArgument, "No changes provided") } - res := s.db.WithContext(ctx). - Model(&model.Video{}). - Where("id = ? AND user_id = ?", id, result.UserID). - Updates(updates) - if res.Error != nil { - s.logger.Error("Failed to update video", "error", res.Error) + if s.videoRepository == nil { + return nil, status.Error(codes.Internal, "Video repository is unavailable") + } + + rowsAffected, err := s.videoRepository.UpdateByIDAndUser(ctx, id, result.UserID, updates) + if err != nil { + s.logger.Error("Failed to update video", "error", err) return nil, status.Error(codes.Internal, "Failed to update video") } - if res.RowsAffected == 0 { + if rowsAffected == 0 { return nil, status.Error(codes.NotFound, "Video not found") } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil { + video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID) + if err != nil { s.logger.Error("Failed to reload video", "error", err) return nil, status.Error(codes.Internal, "Failed to update video") } - payload, err := s.buildVideo(ctx, &video) + payload, err := s.buildVideo(ctx, video) if err != nil { s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID) return nil, status.Error(codes.Internal, "Failed to update video") } return &appv1.UpdateVideoResponse{Video: payload}, nil } -func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) { +func (s *videosAppService) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) { result, err := s.authenticate(ctx) if err != nil { return nil, err @@ -237,8 +227,12 @@ func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoReq return nil, status.Error(codes.NotFound, "Video not found") } - var video model.Video - if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil { + if s.videoRepository == nil { + return nil, status.Error(codes.Internal, "Video repository is unavailable") + } + + video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, status.Error(codes.NotFound, "Video not found") } @@ -260,14 +254,7 @@ func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoReq } } - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil { - return err - } - return tx.Model(&model.User{}). - Where("id = ?", result.UserID). - UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error - }); err != nil { + if err := s.videoRepository.DeleteByIDAndUserWithStorageUpdate(ctx, video.ID, result.UserID, video.Size); err != nil { s.logger.Error("Failed to delete video", "error", err) return nil, status.Error(codes.Internal, "Failed to delete video") } diff --git a/internal/service/usage_helpers.go b/internal/service/usage_helpers.go index dc6b262..3c4e535 100644 --- a/internal/service/usage_helpers.go +++ b/internal/service/usage_helpers.go @@ -3,7 +3,6 @@ package service import ( "context" - "gorm.io/gorm" "stream.api/internal/database/model" "stream.api/pkg/logger" ) @@ -14,9 +13,9 @@ type usagePayload struct { TotalStorage int64 `json:"total_storage"` } -func loadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*usagePayload, error) { - var totalVideos int64 - if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil { +func loadUsage(ctx context.Context, videoRepo VideoRepository, l logger.Logger, user *model.User) (*usagePayload, error) { + totalVideos, err := videoRepo.CountByUser(ctx, user.ID) + if err != nil { l.Error("Failed to count user videos", "error", err, "user_id", user.ID) return nil, err } diff --git a/internal/service/user_mapper.go b/internal/service/user_mapper.go new file mode 100644 index 0000000..95d6cdc --- /dev/null +++ b/internal/service/user_mapper.go @@ -0,0 +1,86 @@ +package service + +import ( + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" + appv1 "stream.api/internal/api/proto/app/v1" + "stream.api/internal/database/model" +) + +func protoUserFromPayload(user *userPayload) *appv1.User { + if user == nil { + return nil + } + return &appv1.User{ + Id: user.ID, + Email: user.Email, + Username: user.Username, + Avatar: user.Avatar, + Role: user.Role, + GoogleId: user.GoogleID, + StorageUsed: user.StorageUsed, + PlanId: user.PlanID, + PlanStartedAt: timeToProto(user.PlanStartedAt), + PlanExpiresAt: timeToProto(user.PlanExpiresAt), + PlanTermMonths: user.PlanTermMonths, + PlanPaymentMethod: user.PlanPaymentMethod, + PlanExpiringSoon: user.PlanExpiringSoon, + WalletBalance: user.WalletBalance, + Language: user.Language, + Locale: user.Locale, + CreatedAt: timeToProto(user.CreatedAt), + UpdatedAt: timestamppb.New(user.UpdatedAt), + } +} + +func toProtoUser(user *userPayload) *appv1.User { + return protoUserFromPayload(user) +} + +func toProtoPreferences(pref *model.UserPreference) *appv1.Preferences { + if pref == nil { + return nil + } + return &appv1.Preferences{ + EmailNotifications: boolValue(pref.EmailNotifications), + PushNotifications: boolValue(pref.PushNotifications), + MarketingNotifications: pref.MarketingNotifications, + TelegramNotifications: pref.TelegramNotifications, + Language: model.StringValue(pref.Language), + Locale: model.StringValue(pref.Locale), + } +} + +func toProtoNotification(item model.Notification) *appv1.Notification { + return &appv1.Notification{ + Id: item.ID, + Type: normalizeNotificationType(item.Type), + Title: item.Title, + Message: item.Message, + Read: item.IsRead, + ActionUrl: item.ActionURL, + ActionLabel: item.ActionLabel, + CreatedAt: timeToProto(item.CreatedAt), + } +} + +func normalizeNotificationType(value string) string { + lower := strings.ToLower(strings.TrimSpace(value)) + switch { + case strings.Contains(lower, "video"): + return "video" + case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"): + return "payment" + case strings.Contains(lower, "warning"): + return "warning" + case strings.Contains(lower, "error"): + return "error" + case strings.Contains(lower, "success"): + return "success" + case strings.Contains(lower, "system"): + return "system" + default: + return "info" + } +} diff --git a/internal/service/user_payload.go b/internal/service/user_payload.go index a60cf22..f834b33 100644 --- a/internal/service/user_payload.go +++ b/internal/service/user_payload.go @@ -31,13 +31,13 @@ type userPayload struct { UpdatedAt time.Time `json:"updated_at"` } -func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) { - pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID) +func buildUserPayload(ctx context.Context, preferenceRepo UserPreferenceRepository, billingRepo BillingRepository, user *model.User) (*userPayload, error) { + pref, err := preferenceRepo.FindOrCreateByUserID(ctx, user.ID) if err != nil { return nil, err } - walletBalance, err := model.GetWalletBalance(ctx, db, user.ID) + walletBalance, err := billingRepo.GetWalletBalance(ctx, user.ID) if err != nil { return nil, err } @@ -59,7 +59,7 @@ func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*user planExpiringSoon := false now := time.Now().UTC() - subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID) + subscription, err := billingRepo.GetLatestPlanSubscription(ctx, user.ID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } diff --git a/internal/service/value_helpers.go b/internal/service/value_helpers.go new file mode 100644 index 0000000..6df4149 --- /dev/null +++ b/internal/service/value_helpers.go @@ -0,0 +1,83 @@ +package service + +import ( + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" +) + +func stringPointerOrNil(value string) *string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func timeToProto(value *time.Time) *timestamppb.Timestamp { + if value == nil { + return nil + } + return timestamppb.New(value.UTC()) +} + +func boolValue(value *bool) bool { + return value != nil && *value +} + +func stringValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func int32PtrToInt64Ptr(value *int32) *int64 { + if value == nil { + return nil + } + converted := int64(*value) + return &converted +} + +func int64PtrToInt32Ptr(value *int64) *int32 { + if value == nil { + return nil + } + converted := int32(*value) + return &converted +} + +func int32Ptr(value int32) *int32 { + return &value +} + +func protoStringValue(value *string) string { + if value == nil { + return "" + } + return strings.TrimSpace(*value) +} + +func nullableTrimmedStringPtr(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func nullableTrimmedString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/internal/service/video_helpers.go b/internal/service/video_helpers.go new file mode 100644 index 0000000..e15ab92 --- /dev/null +++ b/internal/service/video_helpers.go @@ -0,0 +1,56 @@ +package service + +import ( + "net/url" + "strings" +) + +func normalizeVideoStatusValue(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "processing", "pending": + return "processing" + case "failed", "error": + return "failed" + default: + return "ready" + } +} + +func normalizeVideoStatusFilter(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" || strings.EqualFold(trimmed, "all") { + return "" + } + return normalizeVideoStatusValue(trimmed) +} + +func detectStorageType(rawURL string) string { + if shouldDeleteStoredObject(rawURL) { + return "S3" + } + return "WORKER" +} + +func shouldDeleteStoredObject(rawURL string) bool { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return !strings.HasPrefix(trimmed, "/") + } + return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/") +} + +func extractObjectKey(rawURL string) string { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err != nil { + return trimmed + } + return strings.TrimPrefix(parsed.Path, "/") +} diff --git a/internal/service/video_mapper.go b/internal/service/video_mapper.go new file mode 100644 index 0000000..3342206 --- /dev/null +++ b/internal/service/video_mapper.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" + appv1 "stream.api/internal/api/proto/app/v1" + "stream.api/internal/database/model" +) + +func toProtoVideo(item *model.Video, jobID ...string) *appv1.Video { + if item == nil { + return nil + } + statusValue := stringValue(item.Status) + if statusValue == "" { + statusValue = "ready" + } + var linkedJobID *string + if len(jobID) > 0 { + linkedJobID = stringPointerOrNil(jobID[0]) + } + return &appv1.Video{ + Id: item.ID, + UserId: item.UserID, + Title: item.Title, + Description: item.Description, + Url: item.URL, + Status: strings.ToLower(statusValue), + Size: item.Size, + Duration: item.Duration, + Format: item.Format, + Thumbnail: item.Thumbnail, + ProcessingStatus: item.ProcessingStatus, + StorageType: item.StorageType, + CreatedAt: timeToProto(item.CreatedAt), + UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()), + JobId: linkedJobID, + } +} + +func (s *videosAppService) buildVideo(ctx context.Context, video *model.Video) (*appv1.Video, error) { + if video == nil { + return nil, nil + } + jobID, err := s.loadLatestVideoJobID(ctx, video.ID) + if err != nil { + return nil, err + } + if jobID != nil { + return toProtoVideo(video, *jobID), nil + } + return toProtoVideo(video), nil +} diff --git a/internal/transport/grpc/server.go b/internal/transport/grpc/server.go index a1c66c7..c35fb04 100644 --- a/internal/transport/grpc/server.go +++ b/internal/transport/grpc/server.go @@ -11,6 +11,7 @@ import ( "stream.api/internal/dto" "stream.api/internal/service" "stream.api/internal/transport/mqtt" + renderworkflow "stream.api/internal/workflow/render" "stream.api/pkg/logger" ) @@ -23,9 +24,9 @@ type GRPCModule struct { } func NewGRPCModule(ctx context.Context, cfg *config.Config, db *gorm.DB, rds *redisadapter.RedisAdapter, appLogger logger.Logger) (*GRPCModule, error) { - jobService := service.NewJobService(rds, rds) + jobService := service.NewJobService(db, rds, rds) agentRuntime := NewServer(jobService, cfg.Render.AgentSecret) - videoService := service.NewService(db, jobService) + videoService := renderworkflow.New(db, jobService) grpcServer := grpcpkg.NewServer() module := &GRPCModule{ diff --git a/internal/workflow/render/workflow.go b/internal/workflow/render/workflow.go new file mode 100644 index 0000000..c32a8a2 --- /dev/null +++ b/internal/workflow/render/workflow.go @@ -0,0 +1,228 @@ +package render + +import ( + "context" + "encoding/json" + "errors" + "net/url" + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" + "stream.api/internal/database/model" + "stream.api/internal/dto" + "stream.api/internal/repository" +) + +var ( + ErrUserNotFound = errors.New("user not found") + ErrAdTemplateNotFound = errors.New("ad template not found") + ErrJobServiceUnavailable = errors.New("job service is unavailable") +) + +type JobService interface { + ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) + ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) + ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) + GetJob(ctx context.Context, id string) (*model.Job, error) + CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) + CancelJob(ctx context.Context, id string) error + RetryJob(ctx context.Context, id string) (*model.Job, error) +} + +type Workflow struct { + db *gorm.DB + jobService JobService + userRepository userRepository + workflowRepository videoWorkflowRepository +} + +type userRepository interface { + GetByID(ctx context.Context, userID string) (*model.User, error) +} + +type videoWorkflowRepository interface { + GetUserByID(ctx context.Context, userID string) (*model.User, error) + CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error + MarkVideoJobFailed(ctx context.Context, videoID string) error +} + +type CreateVideoInput struct { + UserID string + Title string + Description *string + URL string + Size int64 + Duration int32 + Format string + AdTemplateID *string +} + +type CreateVideoResult struct { + Video *model.Video + Job model.Job +} + +func New(db *gorm.DB, jobService JobService) *Workflow { + return &Workflow{ + db: db, + jobService: jobService, + userRepository: repository.NewUserRepository(db), + workflowRepository: repository.NewVideoWorkflowRepository(db), + } +} + +func (w *Workflow) CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error) { + if w == nil || w.db == nil { + return nil, gorm.ErrInvalidDB + } + + userID := strings.TrimSpace(input.UserID) + if userID == "" { + return nil, ErrUserNotFound + } + + user, err := w.workflowRepository.GetUserByID(ctx, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrUserNotFound + } + return nil, err + } + + title := strings.TrimSpace(input.Title) + videoURL := strings.TrimSpace(input.URL) + format := strings.TrimSpace(input.Format) + statusValue := "processing" + processingStatus := "PENDING" + storageType := detectStorageType(videoURL) + + video := &model.Video{ + ID: uuid.NewString(), + UserID: user.ID, + Name: title, + Title: title, + Description: nullableTrimmedString(input.Description), + URL: videoURL, + Size: input.Size, + Duration: input.Duration, + Format: format, + Status: model.StringPtr(statusValue), + ProcessingStatus: model.StringPtr(processingStatus), + StorageType: model.StringPtr(storageType), + } + if err := w.workflowRepository.CreateVideoWithStorageAndAd(ctx, video, user.ID, input.AdTemplateID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAdTemplateNotFound + } + return nil, err + } + + if w.jobService == nil { + _ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID) + return nil, ErrJobServiceUnavailable + } + + jobPayload, err := buildJobPayload(video.ID, user.ID, videoURL, format) + if err != nil { + _ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID) + return nil, err + } + + job, err := w.jobService.CreateJob(ctx, user.ID, video.ID, title, jobPayload, 0, 0) + if err != nil { + _ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID) + return nil, err + } + + return &CreateVideoResult{Video: video, Job: *job}, nil +} + +func (w *Workflow) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.ListJobs(ctx, offset, limit) +} + +func (w *Workflow) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.ListJobsByAgent(ctx, agentID, offset, limit) +} + +func (w *Workflow) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.ListJobsByCursor(ctx, agentID, cursor, pageSize) +} + +func (w *Workflow) GetJob(ctx context.Context, id string) (*model.Job, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.GetJob(ctx, id) +} + +func (w *Workflow) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.CreateJob(ctx, userID, videoID, name, config, priority, timeLimit) +} + +func (w *Workflow) CancelJob(ctx context.Context, id string) error { + if w == nil || w.jobService == nil { + return ErrJobServiceUnavailable + } + return w.jobService.CancelJob(ctx, id) +} + +func (w *Workflow) RetryJob(ctx context.Context, id string) (*model.Job, error) { + if w == nil || w.jobService == nil { + return nil, ErrJobServiceUnavailable + } + return w.jobService.RetryJob(ctx, id) +} + +func buildJobPayload(videoID, userID, videoURL, format string) ([]byte, error) { + return json.Marshal(map[string]any{ + "video_id": videoID, + "user_id": userID, + "input_url": videoURL, + "source_url": videoURL, + "format": format, + }) +} + +func nullableTrimmedString(value *string) *string { + if value == nil { + return nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil + } + return &trimmed +} + +func detectStorageType(rawURL string) string { + if shouldDeleteStoredObject(rawURL) { + return "S3" + } + return "WORKER" +} + +func shouldDeleteStoredObject(rawURL string) bool { + trimmed := strings.TrimSpace(rawURL) + if trimmed == "" { + return false + } + parsed, err := url.Parse(trimmed) + if err != nil { + return !strings.HasPrefix(trimmed, "/") + } + return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/") +}