From aa32bb6ed83290c3fde464d4c4423999940a45a1 Mon Sep 17 00:00:00 2001 From: FahdShalhoub Date: Mon, 17 Aug 2026 00:13:22 +0300 Subject: [PATCH] FEAT: Added MOre Video Statuses --- CLAUDE.md | 18 ++++++- cms/docs/docs.go | 47 ++++++++++-------- cms/docs/swagger.json | 47 ++++++++++-------- cms/docs/swagger.yaml | 49 ++++++++++--------- cms/internal/api/api.go | 23 +++++++++ cms/internal/api/videos.go | 48 +++++++++++++++++++ cms/internal/handlers/handlers.go | 24 ++-------- cms/internal/handlers/videos.go | 79 ++++++++----------------------- cms/internal/services/db.go | 4 +- cms/internal/services/status.go | 23 +++++++++ 10 files changed, 216 insertions(+), 146 deletions(-) create mode 100644 cms/internal/api/api.go create mode 100644 cms/internal/api/videos.go create mode 100644 cms/internal/services/status.go diff --git a/CLAUDE.md b/CLAUDE.md index ed08b99..f946a55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,13 @@ nothing is fetched from a CDN at runtime. Handlers live in `cms/internal/handlers` — `handlers.go` holds `Health` and the shared response writers, `videos.go` the categories and upload endpoints. +The request/response bodies they serve are *not* in that package: every wire +struct lives in `cms/internal/api` (`api.go` for `HealthResponse` and +`ProblemDetails`, `videos.go` for the rest), exported and referenced by the +handlers' swaggo annotations as `api.VideoResponse` and so on — so the +generated spec's definition names track that package, and renaming a type +there changes the published schema names. + There is no view layer: the `internal/views` templ package, the `static/` directory, and the htmx frontend were all removed when the service became a JSON API, along with the `templ` dependency. @@ -111,6 +118,12 @@ it ever matters. (free-text, comma-separated — not normalized), `file_name`, `storage_key` (the S3 key, unique), `mediaconvert_job_id`, `status` (written once as `"processing"` on insert — see Known gaps), `size_bytes`, timestamps. + `status` is an enum on both sides: the `services.VideoStatus` string type + in `cms/internal/services/status.go` (`processing`, `ready`, `failed`) and + the `videos_status_check` CHECK constraint added by migration `0003`. + Extending it means adding a constant there, widening the constraint in a + new migration, and extending the `enums` annotation on + `api.VideoResponse.Status` before regenerating the spec. `id` is a `UUID` generated application-side as a UUIDv7 (`uuid.NewV7()` in `services.DBConcrete.CreateVideo`) rather than via the column's own `DEFAULT gen_random_uuid()` (which generates v4 and is never @@ -219,8 +232,9 @@ on boot if any required var is empty. and configures no custom MediaConvert endpoint — relies on the SDK's default regional endpoint. - No MediaConvert completion webhook or poller exists — a `videos.status` - row is written once as `"processing"` in `CreateVideo` and never updated, - even after the transcode job actually finishes or fails. + row is written once as `VideoStatusProcessing` in `CreateVideo` and never + updated, even after the transcode job actually finishes or fails. The + `ready` and `failed` states are defined but nothing sets them yet. - `discovery` has infra provisioned (ECR repo, ECS service, ALB, Postgres DB/role) but no application code — it doesn't touch its database at all. - No automated tests exist for `cms`, `discovery`, or `infrastructure`. diff --git a/cms/docs/docs.go b/cms/docs/docs.go index d480597..539ffeb 100644 --- a/cms/docs/docs.go +++ b/cms/docs/docs.go @@ -29,13 +29,13 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.categoriesResponse" + "$ref": "#/definitions/api.CategoriesResponse" } }, "500": { "description": "Categories could not be read from the database", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -43,7 +43,7 @@ const docTemplate = `{ }, "/api/videos": { "post": { - "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.", + "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\" — one of the states \"processing\", \"ready\" or \"failed\" — though nothing currently moves it on once transcoding finishes.", "consumes": [ "application/json" ], @@ -61,7 +61,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.completeRequest" + "$ref": "#/definitions/api.CompleteRequest" } } ], @@ -69,25 +69,25 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handlers.videoResponse" + "$ref": "#/definitions/api.VideoResponse" } }, "400": { "description": "Request body was not valid JSON", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "422": { "description": "title was empty, or key was missing or not a key issued by this API", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "500": { "description": "Transcoding job could not be queued, or the video record could not be saved", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -113,7 +113,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.presignRequest" + "$ref": "#/definitions/api.PresignRequest" } } ], @@ -121,25 +121,25 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.presignResponse" + "$ref": "#/definitions/api.PresignResponse" } }, "400": { "description": "Request body was not valid JSON", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "422": { "description": "contentType was not 'video/mp4' or 'video/quicktime'", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "500": { "description": "Upload URL could not be issued", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -159,7 +159,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.healthResponse" + "$ref": "#/definitions/api.HealthResponse" } } } @@ -167,7 +167,7 @@ const docTemplate = `{ } }, "definitions": { - "handlers.categoriesResponse": { + "api.CategoriesResponse": { "type": "object", "properties": { "categories": { @@ -182,7 +182,7 @@ const docTemplate = `{ } } }, - "handlers.completeRequest": { + "api.CompleteRequest": { "type": "object", "properties": { "categories": { @@ -217,7 +217,7 @@ const docTemplate = `{ } } }, - "handlers.healthResponse": { + "api.HealthResponse": { "type": "object", "properties": { "status": { @@ -226,7 +226,7 @@ const docTemplate = `{ } } }, - "handlers.presignRequest": { + "api.PresignRequest": { "type": "object", "properties": { "contentType": { @@ -243,7 +243,7 @@ const docTemplate = `{ } } }, - "handlers.presignResponse": { + "api.PresignResponse": { "type": "object", "properties": { "key": { @@ -256,7 +256,7 @@ const docTemplate = `{ } } }, - "handlers.problemDetails": { + "api.ProblemDetails": { "type": "object", "properties": { "detail": { @@ -277,7 +277,7 @@ const docTemplate = `{ } } }, - "handlers.videoResponse": { + "api.VideoResponse": { "type": "object", "properties": { "categories": { @@ -315,6 +315,11 @@ const docTemplate = `{ }, "status": { "type": "string", + "enum": [ + "processing", + "ready", + "failed" + ], "example": "processing" }, "storageKey": { diff --git a/cms/docs/swagger.json b/cms/docs/swagger.json index 085a864..0d93c6b 100644 --- a/cms/docs/swagger.json +++ b/cms/docs/swagger.json @@ -22,13 +22,13 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.categoriesResponse" + "$ref": "#/definitions/api.CategoriesResponse" } }, "500": { "description": "Categories could not be read from the database", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -36,7 +36,7 @@ }, "/api/videos": { "post": { - "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\"; note that nothing currently updates that status once transcoding finishes.", + "description": "Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status \"processing\" — one of the states \"processing\", \"ready\" or \"failed\" — though nothing currently moves it on once transcoding finishes.", "consumes": [ "application/json" ], @@ -54,7 +54,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.completeRequest" + "$ref": "#/definitions/api.CompleteRequest" } } ], @@ -62,25 +62,25 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/handlers.videoResponse" + "$ref": "#/definitions/api.VideoResponse" } }, "400": { "description": "Request body was not valid JSON", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "422": { "description": "title was empty, or key was missing or not a key issued by this API", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "500": { "description": "Transcoding job could not be queued, or the video record could not be saved", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -106,7 +106,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/handlers.presignRequest" + "$ref": "#/definitions/api.PresignRequest" } } ], @@ -114,25 +114,25 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.presignResponse" + "$ref": "#/definitions/api.PresignResponse" } }, "400": { "description": "Request body was not valid JSON", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "422": { "description": "contentType was not 'video/mp4' or 'video/quicktime'", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } }, "500": { "description": "Upload URL could not be issued", "schema": { - "$ref": "#/definitions/handlers.problemDetails" + "$ref": "#/definitions/api.ProblemDetails" } } } @@ -152,7 +152,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/handlers.healthResponse" + "$ref": "#/definitions/api.HealthResponse" } } } @@ -160,7 +160,7 @@ } }, "definitions": { - "handlers.categoriesResponse": { + "api.CategoriesResponse": { "type": "object", "properties": { "categories": { @@ -175,7 +175,7 @@ } } }, - "handlers.completeRequest": { + "api.CompleteRequest": { "type": "object", "properties": { "categories": { @@ -210,7 +210,7 @@ } } }, - "handlers.healthResponse": { + "api.HealthResponse": { "type": "object", "properties": { "status": { @@ -219,7 +219,7 @@ } } }, - "handlers.presignRequest": { + "api.PresignRequest": { "type": "object", "properties": { "contentType": { @@ -236,7 +236,7 @@ } } }, - "handlers.presignResponse": { + "api.PresignResponse": { "type": "object", "properties": { "key": { @@ -249,7 +249,7 @@ } } }, - "handlers.problemDetails": { + "api.ProblemDetails": { "type": "object", "properties": { "detail": { @@ -270,7 +270,7 @@ } } }, - "handlers.videoResponse": { + "api.VideoResponse": { "type": "object", "properties": { "categories": { @@ -308,6 +308,11 @@ }, "status": { "type": "string", + "enum": [ + "processing", + "ready", + "failed" + ], "example": "processing" }, "storageKey": { diff --git a/cms/docs/swagger.yaml b/cms/docs/swagger.yaml index ac94f0c..57c93f1 100644 --- a/cms/docs/swagger.yaml +++ b/cms/docs/swagger.yaml @@ -1,6 +1,6 @@ basePath: / definitions: - handlers.categoriesResponse: + api.CategoriesResponse: properties: categories: example: @@ -10,7 +10,7 @@ definitions: type: string type: array type: object - handlers.completeRequest: + api.CompleteRequest: properties: categories: example: @@ -35,13 +35,13 @@ definitions: example: Inside the Newsroom type: string type: object - handlers.healthResponse: + api.HealthResponse: properties: status: example: ok type: string type: object - handlers.presignRequest: + api.PresignRequest: properties: contentType: enum: @@ -53,7 +53,7 @@ definitions: example: interview-cut.mov type: string type: object - handlers.presignResponse: + api.PresignResponse: properties: key: example: videos/a1b2c3d4e5f6.mov @@ -62,7 +62,7 @@ definitions: example: https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=... type: string type: object - handlers.problemDetails: + api.ProblemDetails: properties: detail: example: The 'title' field was absent or contained only whitespace. Every @@ -78,7 +78,7 @@ definitions: example: about:blank type: string type: object - handlers.videoResponse: + api.VideoResponse: properties: categories: example: @@ -105,6 +105,10 @@ definitions: example: 60 type: integer status: + enum: + - processing + - ready + - failed example: processing type: string storageKey: @@ -138,11 +142,11 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.categoriesResponse' + $ref: '#/definitions/api.CategoriesResponse' "500": description: Categories could not be read from the database schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' summary: List video categories tags: - categories @@ -153,8 +157,9 @@ paths: description: Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. - The returned record has status "processing"; note that nothing currently updates - that status once transcoding finishes. + The returned record has status "processing" — one of the states "processing", + "ready" or "failed" — though nothing currently moves it on once transcoding + finishes. parameters: - description: Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories. @@ -162,28 +167,28 @@ paths: name: request required: true schema: - $ref: '#/definitions/handlers.completeRequest' + $ref: '#/definitions/api.CompleteRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/handlers.videoResponse' + $ref: '#/definitions/api.VideoResponse' "400": description: Request body was not valid JSON schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' "422": description: title was empty, or key was missing or not a key issued by this API schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' "500": description: Transcoding job could not be queued, or the video record could not be saved schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' summary: Register an uploaded video tags: - videos @@ -204,26 +209,26 @@ paths: name: request required: true schema: - $ref: '#/definitions/handlers.presignRequest' + $ref: '#/definitions/api.PresignRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/handlers.presignResponse' + $ref: '#/definitions/api.PresignResponse' "400": description: Request body was not valid JSON schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' "422": description: contentType was not 'video/mp4' or 'video/quicktime' schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' "500": description: Upload URL could not be issued schema: - $ref: '#/definitions/handlers.problemDetails' + $ref: '#/definitions/api.ProblemDetails' summary: Create a presigned upload URL tags: - videos @@ -237,7 +242,7 @@ paths: "200": description: OK schema: - $ref: '#/definitions/handlers.healthResponse' + $ref: '#/definitions/api.HealthResponse' summary: Health check tags: - system diff --git a/cms/internal/api/api.go b/cms/internal/api/api.go new file mode 100644 index 0000000..b039162 --- /dev/null +++ b/cms/internal/api/api.go @@ -0,0 +1,23 @@ +// Package api holds the request and response bodies of the HTTP API — the +// wire contract, kept apart from the handlers that serve it. The swaggo +// annotations on the handlers reference these types by name (api.VideoResponse +// and so on), so renaming one changes the generated spec. +package api + +// HealthResponse is the body of GET /health. +type HealthResponse struct { + Status string `json:"status" example:"ok"` +} + +// ProblemDetails is an error body in the RFC 9457 "Problem Details for HTTP +// APIs" format. Type stays "about:blank" — the value RFC 9457 defines for +// problems with no dedicated documentation URI. Title is a short summary that +// stays identical for every occurrence of the same problem, so clients can +// branch on it; Detail explains this particular occurrence and is the only +// member that varies with request data. +type ProblemDetails struct { + Type string `json:"type" example:"about:blank"` + Title string `json:"title" example:"Title is required."` + Status int `json:"status" example:"422"` + Detail string `json:"detail,omitempty" example:"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title."` +} diff --git a/cms/internal/api/videos.go b/cms/internal/api/videos.go new file mode 100644 index 0000000..e202995 --- /dev/null +++ b/cms/internal/api/videos.go @@ -0,0 +1,48 @@ +package api + +import "time" + +// CategoriesResponse is the body of GET /api/categories. +type CategoriesResponse struct { + Categories []string `json:"categories" example:"documentary,news"` +} + +// PresignRequest is the body of POST /api/videos/presign. +type PresignRequest struct { + FileName string `json:"fileName" example:"interview-cut.mov"` + ContentType string `json:"contentType" enums:"video/mp4,video/quicktime" example:"video/quicktime"` +} + +// PresignResponse is the body returned by POST /api/videos/presign. +type PresignResponse struct { + UploadURL string `json:"uploadUrl" example:"https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..."` + Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` +} + +// CompleteRequest is the body of POST /api/videos. +type CompleteRequest struct { + Title string `json:"title" example:"Inside the Newsroom"` + Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` + Categories []string `json:"categories" example:"documentary,news"` + Tags string `json:"tags" example:"media, press, riyadh"` + FileName string `json:"fileName" example:"interview-cut.mov"` + Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` +} + +// VideoResponse is a persisted video record as returned by POST /api/videos. +// Status carries the values of services.VideoStatus; the enums tag has to be +// widened by hand whenever that type gains a state. +type VideoResponse struct { + ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"` + Title string `json:"title" example:"Inside the Newsroom"` + Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` + Categories []string `json:"categories" example:"documentary,news"` + Tags string `json:"tags" example:"media, press, riyadh"` + FileName string `json:"fileName" example:"interview-cut.mov"` + StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"` + MediaConvertJobID string `json:"mediaConvertJobId" example:"1755300000000-abcdef"` + Status string `json:"status" enums:"processing,ready,failed" example:"processing"` + SizeBytes int64 `json:"sizeBytes" example:"60"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/cms/internal/handlers/handlers.go b/cms/internal/handlers/handlers.go index 0b83e57..1a11fc4 100644 --- a/cms/internal/handlers/handlers.go +++ b/cms/internal/handlers/handlers.go @@ -4,35 +4,19 @@ import ( "encoding/json" "log" "net/http" + "thamanyah/cms/v2/internal/api" ) -type healthResponse struct { - Status string `json:"status" example:"ok"` -} - // Health is the liveness probe the ALB target group polls. // // @Summary Health check // @Description Reports that the service is up and serving. Used as the ALB target group health check; it does not verify the database or S3 connections. // @Tags system // @Produce json -// @Success 200 {object} healthResponse +// @Success 200 {object} api.HealthResponse // @Router /health [get] func Health(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, healthResponse{Status: "ok"}) -} - -// problemDetails is an error body in the RFC 9457 "Problem Details for HTTP -// APIs" format. Type stays "about:blank" — the value RFC 9457 defines for -// problems with no dedicated documentation URI. Title is a short summary that -// stays identical for every occurrence of the same problem, so clients can -// branch on it; Detail explains this particular occurrence and is the only -// member that varies with request data. -type problemDetails struct { - Type string `json:"type" example:"about:blank"` - Title string `json:"title" example:"Title is required."` - Status int `json:"status" example:"422"` - Detail string `json:"detail,omitempty" example:"The 'title' field was absent or contained only whitespace. Every video needs a non-empty title."` + writeJSON(w, http.StatusOK, api.HealthResponse{Status: "ok"}) } func writeJSON(w http.ResponseWriter, status int, body any) { @@ -40,7 +24,7 @@ func writeJSON(w http.ResponseWriter, status int, body any) { } func writeProblem(w http.ResponseWriter, status int, title, detail string) { - writeJSONContent(w, "application/problem+json", status, problemDetails{ + writeJSONContent(w, "application/problem+json", status, api.ProblemDetails{ Type: "about:blank", Title: title, Status: status, diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index 2c82d89..bc6fab8 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -9,6 +9,7 @@ import ( "net/http" "path/filepath" "strings" + "thamanyah/cms/v2/internal/api" "thamanyah/cms/v2/internal/services" "time" ) @@ -19,18 +20,14 @@ const ( maxJSONBodySize = 1 << 20 // 1 MiB ) -type categoriesResponse struct { - Categories []string `json:"categories" example:"documentary,news"` -} - // ListCategories returns the categories a video may be assigned to. // // @Summary List video categories // @Description Returns the fixed lookup set of categories a video can belong to. Values from this list are the only ones accepted in the `categories` field of POST /api/videos. // @Tags categories // @Produce json -// @Success 200 {object} categoriesResponse -// @Failure 500 {object} problemDetails "Categories could not be read from the database" +// @Success 200 {object} api.CategoriesResponse +// @Failure 500 {object} api.ProblemDetails "Categories could not be read from the database" // @Router /api/categories [get] func ListCategories(w http.ResponseWriter, r *http.Request) { categories, err := services.DB.ListCategories(r.Context()) @@ -44,7 +41,7 @@ func ListCategories(w http.ResponseWriter, r *http.Request) { if categories == nil { categories = []string{} } - writeJSON(w, http.StatusOK, categoriesResponse{Categories: categories}) + writeJSON(w, http.StatusOK, api.CategoriesResponse{Categories: categories}) } // allowedUploadContentTypes is the set of media types accepted for upload, @@ -57,16 +54,6 @@ var allowedUploadContentTypes = map[string]struct{}{ "video/quicktime": {}, // .mov } -type presignRequest struct { - FileName string `json:"fileName" example:"interview-cut.mov"` - ContentType string `json:"contentType" enums:"video/mp4,video/quicktime" example:"video/quicktime"` -} - -type presignResponse struct { - UploadURL string `json:"uploadUrl" example:"https://raw-uploads-bucket.s3.amazonaws.com/videos/a1b2....mov?X-Amz-Signature=..."` - Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` -} - // PresignVideoUpload issues a presigned S3 PUT URL for a video upload. // // @Summary Create a presigned upload URL @@ -74,16 +61,16 @@ type presignResponse struct { // @Tags videos // @Accept json // @Produce json -// @Param request body presignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'." -// @Success 200 {object} presignResponse -// @Failure 400 {object} problemDetails "Request body was not valid JSON" -// @Failure 422 {object} problemDetails "contentType was not 'video/mp4' or 'video/quicktime'" -// @Failure 500 {object} problemDetails "Upload URL could not be issued" +// @Param request body api.PresignRequest true "Name and media type of the file to be uploaded. contentType must be exactly 'video/mp4' or 'video/quicktime'." +// @Success 200 {object} api.PresignResponse +// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON" +// @Failure 422 {object} api.ProblemDetails "contentType was not 'video/mp4' or 'video/quicktime'" +// @Failure 500 {object} api.ProblemDetails "Upload URL could not be issued" // @Router /api/videos/presign [post] func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize) - var req presignRequest + var req api.PresignRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeProblem(w, http.StatusBadRequest, "Invalid request.", fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object with the string fields 'fileName' and 'contentType', and keep the body under 1 MiB.", err)) @@ -111,50 +98,26 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, presignResponse{UploadURL: presignedURL, Key: key}) -} - -type completeRequest struct { - Title string `json:"title" example:"Inside the Newsroom"` - Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` - Categories []string `json:"categories" example:"documentary,news"` - Tags string `json:"tags" example:"media, press, riyadh"` - FileName string `json:"fileName" example:"interview-cut.mov"` - Key string `json:"key" example:"videos/a1b2c3d4e5f6.mov"` -} - -type videoResponse struct { - ID string `json:"id" example:"0199f3a1-7c2e-7b21-9f0d-1a2b3c4d5e6f"` - Title string `json:"title" example:"Inside the Newsroom"` - Description string `json:"description" example:"A behind-the-scenes look at the evening bulletin."` - Categories []string `json:"categories" example:"documentary,news"` - Tags string `json:"tags" example:"media, press, riyadh"` - FileName string `json:"fileName" example:"interview-cut.mov"` - StorageKey string `json:"storageKey" example:"videos/a1b2c3d4e5f6.mov"` - MediaConvertJobID string `json:"mediaConvertJobId" example:"1755300000000-abcdef"` - Status string `json:"status" example:"processing"` - SizeBytes int64 `json:"sizeBytes" example:"60"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + writeJSON(w, http.StatusOK, api.PresignResponse{UploadURL: presignedURL, Key: key}) } // CompleteVideoUpload registers an uploaded video and queues it for transcoding. // // @Summary Register an uploaded video -// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing"; note that nothing currently updates that status once transcoding finishes. +// @Description Step 2 of the upload flow. Takes the storage key returned by POST /api/videos/presign — after the file has been PUT to the presigned URL — submits a MediaConvert transcoding job, and persists the video with its category links. The returned record has status "processing" — one of the states "processing", "ready" or "failed" — though nothing currently moves it on once transcoding finishes. // @Tags videos // @Accept json // @Produce json -// @Param request body completeRequest true "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories." -// @Success 201 {object} videoResponse -// @Failure 400 {object} problemDetails "Request body was not valid JSON" -// @Failure 422 {object} problemDetails "title was empty, or key was missing or not a key issued by this API" -// @Failure 500 {object} problemDetails "Transcoding job could not be queued, or the video record could not be saved" +// @Param request body api.CompleteRequest true "Video metadata plus the storage key from the presign step. title and key are required; categories must be values from GET /api/categories." +// @Success 201 {object} api.VideoResponse +// @Failure 400 {object} api.ProblemDetails "Request body was not valid JSON" +// @Failure 422 {object} api.ProblemDetails "title was empty, or key was missing or not a key issued by this API" +// @Failure 500 {object} api.ProblemDetails "Transcoding job could not be queued, or the video record could not be saved" // @Router /api/videos [post] func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize) - var req completeRequest + var req api.CompleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeProblem(w, http.StatusBadRequest, "Invalid request.", fmt.Sprintf("The request body could not be parsed as JSON (%s). Send an object containing at least 'title' and 'key', and keep the body under 1 MiB.", err)) @@ -198,7 +161,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { FileName: strings.TrimSpace(req.FileName), StorageKey: key, MediaConvertJobID: jobID, - Status: "processing", + Status: services.VideoStatusProcessing, SizeBytes: 60, }) if err != nil { @@ -208,7 +171,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusCreated, videoResponse{ + writeJSON(w, http.StatusCreated, api.CompleteResponse{ ID: video.ID, Title: video.Title, Description: video.Description, @@ -217,7 +180,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { FileName: video.FileName, StorageKey: video.StorageKey, MediaConvertJobID: video.MediaConvertJobID, - Status: video.Status, + Status: video.Status.String(), SizeBytes: video.SizeBytes, CreatedAt: video.CreatedAt, UpdatedAt: video.UpdatedAt, diff --git a/cms/internal/services/db.go b/cms/internal/services/db.go index 86894c7..70f6e0f 100644 --- a/cms/internal/services/db.go +++ b/cms/internal/services/db.go @@ -20,7 +20,7 @@ type Video struct { FileName string StorageKey string MediaConvertJobID string - Status string + Status VideoStatus SizeBytes int64 CreatedAt time.Time UpdatedAt time.Time @@ -56,7 +56,7 @@ func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) { INSERT INTO videos (title, description, tags, file_name, storage_key, mediaconvert_job_id, status, size_bytes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at - `, v.Title, v.Description, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, v.Status, v.SizeBytes) + `, v.Title, v.Description, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, string(v.Status), v.SizeBytes) if err := row.Scan(&v.ID, &v.CreatedAt, &v.UpdatedAt); err != nil { return Video{}, err diff --git a/cms/internal/services/status.go b/cms/internal/services/status.go new file mode 100644 index 0000000..70799f5 --- /dev/null +++ b/cms/internal/services/status.go @@ -0,0 +1,23 @@ +package services + +// VideoStatus is the transcoding lifecycle state of a video, stored verbatim +// in videos.status. The set is closed on both sides: the database rejects +// anything outside it via the videos_status_check constraint. Adding a state +// means adding a constant here, widening that constraint in a new migration, +// and extending the `enums` annotation on api.VideoResponse.Status. +type VideoStatus string + +const ( + // VideoStatusProcessing is set when the MediaConvert job is queued. Every + // video starts here, and nothing moves it on yet — see Known gaps. + VideoStatusProcessing VideoStatus = "processing" + // VideoStatusReady means transcoding finished and the output is in the + // encoded bucket, reachable through CloudFront. + VideoStatusReady VideoStatus = "ready" + // VideoStatusFailed means the MediaConvert job ended in an error. + VideoStatusFailed VideoStatus = "failed" +) + +func (s VideoStatus) String() string { + return string(s) +}