diff --git a/cms/internal/db/migrations/0002_normalize_video_categories.down.sql b/cms/internal/db/migrations/0002_normalize_video_categories.down.sql new file mode 100644 index 0000000..3068f59 --- /dev/null +++ b/cms/internal/db/migrations/0002_normalize_video_categories.down.sql @@ -0,0 +1,3 @@ +DROP TABLE video_categories; + +ALTER TABLE videos ADD COLUMN category_id SMALLINT REFERENCES categories(id); diff --git a/cms/internal/db/migrations/0002_normalize_video_categories.up.sql b/cms/internal/db/migrations/0002_normalize_video_categories.up.sql new file mode 100644 index 0000000..da6cdcd --- /dev/null +++ b/cms/internal/db/migrations/0002_normalize_video_categories.up.sql @@ -0,0 +1,7 @@ +ALTER TABLE videos DROP COLUMN category_id; + +CREATE TABLE video_categories ( + video_id UUID NOT NULL REFERENCES videos(id) ON DELETE CASCADE, + category_id SMALLINT NOT NULL REFERENCES categories(id), + PRIMARY KEY (video_id, category_id) +); diff --git a/cms/internal/handlers/videos.go b/cms/internal/handlers/videos.go index 9ada9f8..c12e7ca 100644 --- a/cms/internal/handlers/videos.go +++ b/cms/internal/handlers/videos.go @@ -66,12 +66,12 @@ func PresignVideoUpload(w http.ResponseWriter, r *http.Request) { } type completeRequest struct { - Title string `json:"title"` - Description string `json:"description"` - Category string `json:"category"` - Tags string `json:"tags"` - FileName string `json:"fileName"` - Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + Categories []string `json:"categories"` + Tags string `json:"tags"` + FileName string `json:"fileName"` + Key string `json:"key"` } func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { @@ -95,6 +95,13 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { return } + categories := make([]string, 0, len(req.Categories)) + for _, category := range req.Categories { + if category = strings.TrimSpace(category); category != "" { + categories = append(categories, category) + } + } + jobID, err := services.MediaConvertClient.QueueEncodingJob(r.Context(), key) if err != nil { log.Printf("Something Went Wrong On Creation Of Transcoding Job: %s", err) @@ -105,7 +112,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { _, err = services.DB.CreateVideo(r.Context(), services.Video{ Title: title, Description: strings.TrimSpace(req.Description), - Category: strings.TrimSpace(req.Category), + Categories: categories, Tags: strings.TrimSpace(req.Tags), FileName: strings.TrimSpace(req.FileName), StorageKey: key, @@ -122,7 +129,7 @@ func CompleteVideoUpload(w http.ResponseWriter, r *http.Request) { views.VideoUploadSuccess(views.VideoMetadata{ Title: title, Description: strings.TrimSpace(req.Description), - Category: strings.TrimSpace(req.Category), + Categories: categories, Tags: strings.TrimSpace(req.Tags), FileName: strings.TrimSpace(req.FileName), JobID: jobID, diff --git a/cms/internal/services/db.go b/cms/internal/services/db.go index 2039159..7a4845e 100644 --- a/cms/internal/services/db.go +++ b/cms/internal/services/db.go @@ -6,6 +6,8 @@ import ( "fmt" "thamanyah/cms/v2/internal/db" "time" + + "github.com/google/uuid" ) var DB DBClient @@ -14,7 +16,7 @@ type Video struct { ID string Title string Description string - Category string + Categories []string Tags string FileName string StorageKey string @@ -34,26 +36,48 @@ type DBConcrete struct { } func (svc DBConcrete) CreateVideo(ctx context.Context, v Video) (Video, error) { - db, err := sql.Open("postgres", svc.ConnectionString) + sqlDB, err := sql.Open("postgres", svc.ConnectionString) if err != nil { return Video{}, err } - defer db.Close() + defer sqlDB.Close() - var categoryID int64 - if err := db.QueryRowContext(ctx, `SELECT id FROM categories WHERE name = $1`, v.Category).Scan(&categoryID); err != nil { - return Video{}, fmt.Errorf("looking up category %q: %w", v.Category, err) + id, err := uuid.NewV7() + if err != nil { + return Video{}, fmt.Errorf("generating video id: %w", err) } - row := db.QueryRowContext(ctx, ` - INSERT INTO videos (title, description, category_id, tags, file_name, storage_key, mediaconvert_job_id, status, size_bytes) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + tx, err := sqlDB.BeginTx(ctx, nil) + if err != nil { + return Video{}, err + } + defer tx.Rollback() + + row := tx.QueryRowContext(ctx, ` + INSERT INTO videos (id, title, description, tags, file_name, storage_key, mediaconvert_job_id, status, size_bytes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING created_at, updated_at - `, v.Title, v.Description, categoryID, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, v.Status, v.SizeBytes) + `, id, v.Title, v.Description, v.Tags, v.FileName, v.StorageKey, v.MediaConvertJobID, v.Status, v.SizeBytes) if err := row.Scan(&v.CreatedAt, &v.UpdatedAt); err != nil { return Video{}, err } + + for _, category := range v.Categories { + var categoryID int64 + if err := tx.QueryRowContext(ctx, `SELECT id FROM categories WHERE name = $1`, category).Scan(&categoryID); err != nil { + return Video{}, fmt.Errorf("looking up category %q: %w", category, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO video_categories (video_id, category_id) VALUES ($1, $2)`, id, categoryID); err != nil { + return Video{}, fmt.Errorf("linking category %q: %w", category, err) + } + } + + if err := tx.Commit(); err != nil { + return Video{}, err + } + + v.ID = id.String() return v, nil } @@ -71,13 +95,13 @@ func (svc DBConcrete) Migrate() { // AssertSuccessfulConnection verifies the database is reachable, mirroring // S3Concrete's boot-time check — panics rather than let the service come up broken. func (svc DBConcrete) AssertSuccessfulConnection(ctx context.Context) { - db, err := sql.Open("postgres", svc.ConnectionString) + sqlDB, err := sql.Open("postgres", svc.ConnectionString) if err != nil { panic(fmt.Errorf("db: cannot open connection: %w", err)) } - defer db.Close() + defer sqlDB.Close() - if err := db.PingContext(ctx); err != nil { + if err := sqlDB.PingContext(ctx); err != nil { panic(fmt.Errorf("db: cannot connect: %w", err)) } } diff --git a/cms/internal/views/types.go b/cms/internal/views/types.go index 53e925f..867beed 100644 --- a/cms/internal/views/types.go +++ b/cms/internal/views/types.go @@ -1,11 +1,14 @@ package views -import "fmt" +import ( + "fmt" + "strings" +) type VideoMetadata struct { Title string Description string - Category string + Categories []string Tags string FileName string StoredAs string @@ -13,6 +16,10 @@ type VideoMetadata struct { SizeBytes int64 } +func joinCategories(categories []string) string { + return strings.Join(categories, ", ") +} + func formatSize(n int64) string { const unit = 1024 if n < unit { diff --git a/cms/internal/views/video.templ b/cms/internal/views/video.templ index 1f33abb..b595d6e 100644 --- a/cms/internal/views/video.templ +++ b/cms/internal/views/video.templ @@ -16,8 +16,8 @@ templ VideoUpload() {
- - @@ -103,7 +103,7 @@ templ VideoUpload() { body: JSON.stringify({ title: form.title.value.trim(), description: form.description.value.trim(), - category: form.category.value, + categories: Array.prototype.map.call(form.category.selectedOptions, function (o) { return o.value; }), tags: form.tags.value.trim(), fileName: file.name, key: key @@ -133,8 +133,8 @@ templ VideoUploadSuccess(v VideoMetadata) {
Description
{ v.Description }
} -
Category
-
{ v.Category }
+
Categories
+
{ joinCategories(v.Categories) }
Enqueue Job ID
{ v.JobID }
if v.Tags != "" { diff --git a/cms/internal/views/video_templ.go b/cms/internal/views/video_templ.go index 11a107c..f78c0e6 100644 --- a/cms/internal/views/video_templ.go +++ b/cms/internal/views/video_templ.go @@ -43,7 +43,7 @@ func VideoUpload() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Upload a video

Provide the video file along with its metadata.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Upload a video

Provide the video file along with its metadata.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -114,69 +114,82 @@ func VideoUploadSuccess(v VideoMetadata) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Category
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Categories
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.Category) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(joinCategories(v.Categories)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 137, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 137, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
Enqueue Job ID
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.JobID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 139, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Tags != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
Tags
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Tags
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Tags) + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Tags) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 140, Col: 16} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 142, Col: 16} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
File
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.FileName) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 144, Col: 16} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " (") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
File
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(formatSize(v.SizeBytes)) + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.FileName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 144, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 146, Col: 16} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ")
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(formatSize(v.SizeBytes)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 146, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -200,25 +213,25 @@ func VideoUploadError(message string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(message) + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(message) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 152, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/video.templ`, Line: 154, Col: 34} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }