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() {
") + 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, "