diff --git a/db/queries/posts.sql b/db/queries/posts.sql index bc6e319..65d72ed 100644 --- a/db/queries/posts.sql +++ b/db/queries/posts.sql @@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id); -- name: CreatePostImage :exec INSERT INTO post_images ( - id, post_id, object_key, public_url, description, position, width, height, created_at + id, post_id, object_key, public_url, description, kind, position, width, height, created_at ) VALUES ( sqlc.arg(id), @@ -71,6 +71,7 @@ VALUES ( sqlc.arg(object_key), sqlc.arg(public_url), sqlc.arg(description), + sqlc.arg(kind), sqlc.arg(position), sqlc.arg(width), sqlc.arg(height), @@ -83,7 +84,7 @@ WHERE post_id = sqlc.arg(post_id); -- name: ListPostImages :many SELECT - id, post_id, object_key, public_url, description, position, width, height, created_at + id, post_id, object_key, public_url, description, kind, position, width, height, created_at FROM post_images WHERE post_id = sqlc.arg(post_id) ORDER BY position; @@ -102,7 +103,7 @@ WITH RECURSIVE thread AS ( ) SELECT images.id, images.post_id, images.object_key, images.public_url, - images.description, images.position, images.width, images.height, images.created_at + images.description, images.kind, images.position, images.width, images.height, images.created_at FROM post_images images JOIN thread ON thread.id = images.post_id ORDER BY images.post_id, images.position; diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 28638e9..3dd26e9 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -158,6 +158,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx return nil } +func migratePostImageVideo(ctx context.Context, exec execContext) error { + steps := []struct { + name string + sql string + }{ + {"add kind", `ALTER TABLE post_images ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'image'`}, + {"drop kind check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_kind_check`}, + {"add kind check", `ALTER TABLE post_images ADD CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video'))`}, + {"drop width check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_width_check`}, + {"drop height check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_height_check`}, + {"add width check", `ALTER TABLE post_images ADD CONSTRAINT post_images_width_check CHECK (width >= 0)`}, + {"add height check", `ALTER TABLE post_images ADD CONSTRAINT post_images_height_check CHECK (height >= 0)`}, + {"drop image dims check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_image_dims_check`}, + {"add image dims check", `ALTER TABLE post_images ADD CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0))`}, + {"drop position check", `ALTER TABLE post_images DROP CONSTRAINT IF EXISTS post_images_position_check`}, + {"add position check", `ALTER TABLE post_images ADD CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4)`}, + {"one video index", `CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx ON post_images (post_id) WHERE kind = 'video'`}, + } + for _, step := range steps { + if _, err := exec.ExecContext(ctx, step.sql); err != nil { + return fmt.Errorf("%s: %w", step.name, err) + } + } + return nil +} + func migratePostDate(ctx context.Context, exec execContext) error { steps := []struct { name string @@ -351,6 +377,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations ( {"009_drop_legacy_post_tables", migrateDropLegacyPostTables}, {"010_post_images", migratePostImages}, {"011_discord_post_links", migrateDiscordPostLinks}, + {"012_post_image_video", migratePostImageVideo}, } for _, m := range migrations { if applied[m.version] { diff --git a/internal/store/migrate_posts_test.go b/internal/store/migrate_posts_test.go index d2dba7c..98c2560 100644 --- a/internal/store/migrate_posts_test.go +++ b/internal/store/migrate_posts_test.go @@ -87,6 +87,12 @@ CREATE TABLE users ( if err := migrateDiscordPostLinks(ctx, conn); err != nil { t.Fatalf("discord post links migration is not idempotent: %v", err) } + if err := migratePostImageVideo(ctx, conn); err != nil { + t.Fatal(err) + } + if err := migratePostImageVideo(ctx, conn); err != nil { + t.Fatalf("post image video migration is not idempotent: %v", err) + } if _, err := conn.ExecContext(ctx, ` INSERT INTO users (id, name, role) VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); @@ -118,9 +124,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil { } imageQueries := sqlc.New(conn) for _, image := range []sqlc.CreatePostImageParams{ - {ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"}, - {ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"}, - {ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"}, + {ID: "root-image-1", PostID: "root-1", ObjectKey: "posts/root-1/1.jpg", PublicUrl: "https://cdn.example/root-1.jpg", Description: "Valve", Kind: "image", Position: 0, Width: 1200, Height: 900, CreatedAt: "2026-08-26T08:00:00Z"}, + {ID: "root-image-2", PostID: "root-1", ObjectKey: "posts/root-1/2.png", PublicUrl: "https://cdn.example/root-2.png", Kind: "image", Position: 1, Width: 900, Height: 1200, CreatedAt: "2026-08-26T08:00:00Z"}, + {ID: "reply-image-1", PostID: "reply-1", ObjectKey: "posts/reply-1/1.jpg", PublicUrl: "https://cdn.example/reply-1.jpg", Description: "Cartridge", Kind: "image", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09:00:00Z"}, } { if err := imageQueries.CreatePostImage(ctx, image); err != nil { t.Fatal(err) @@ -144,10 +150,24 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil { } if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg", - PublicUrl: "https://cdn.example/root-5.jpg", Position: 4, + PublicUrl: "https://cdn.example/root-5.jpg", Kind: "image", Position: 5, Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z", }); err == nil { - t.Fatal("fifth image position unexpectedly succeeded") + t.Fatal("position 5 unexpectedly succeeded") + } + if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ + ID: "root-video-1", PostID: "root-1", ObjectKey: "posts/root-1/clip.mp4", + PublicUrl: "https://cdn.example/clip.mp4", Kind: "video", Position: 2, + Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z", + }); err != nil { + t.Fatal(err) + } + if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ + ID: "root-video-2", PostID: "root-1", ObjectKey: "posts/root-1/clip-2.mp4", + PublicUrl: "https://cdn.example/clip-2.mp4", Kind: "video", Position: 3, + Width: 0, Height: 0, CreatedAt: "2026-08-26T08:00:00Z", + }); err == nil { + t.Fatal("second video unexpectedly succeeded") } if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{ PostID: "root-1", diff --git a/internal/store/post.go b/internal/store/post.go index 8fccf75..e69d86b 100644 --- a/internal/store/post.go +++ b/internal/store/post.go @@ -29,16 +29,20 @@ const ( PostStateHidden PostState = "hidden" PostStateLocked PostState = "locked" MaxPostImages = 4 + MaxPostVideos = 1 MaxImageDescriptionRunes = 500 + MediaKindImage = "image" + MediaKindVideo = "video" ) -// PostImage is one ordered public image attached to a post. +// PostImage is one ordered public image or video attached to a post. type PostImage struct { ID string PostID string ObjectKey string PublicURL string Description string + Kind string Position int Width int Height int @@ -200,12 +204,13 @@ func preparePost(p *Post) error { } func preparePostImages(p *Post) error { - if len(p.Images) > MaxPostImages { - return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages) + if len(p.Images) > MaxPostImages+MaxPostVideos { + return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos) } ids := make(map[string]bool, len(p.Images)) keys := make(map[string]bool, len(p.Images)) now := time.Now().UTC().Format(time.RFC3339Nano) + images, videos := 0, 0 for i := range p.Images { image := &p.Images[i] image.ID = strings.TrimSpace(image.ID) @@ -213,6 +218,13 @@ func preparePostImages(p *Post) error { image.ObjectKey = strings.TrimSpace(image.ObjectKey) image.PublicURL = strings.TrimSpace(image.PublicURL) image.Description = strings.TrimSpace(image.Description) + image.Kind = strings.TrimSpace(image.Kind) + if image.Kind == "" { + image.Kind = MediaKindImage + } + if image.Kind != MediaKindImage && image.Kind != MediaKindVideo { + return fmt.Errorf("%w: invalid media kind", ErrInvalidPost) + } if image.ID == "" { image.ID = uuid.NewString() } @@ -228,9 +240,18 @@ func preparePostImages(p *Post) error { if len([]rune(image.Description)) > MaxImageDescriptionRunes { return fmt.Errorf("%w: image description is too long", ErrInvalidPost) } - if image.Width <= 0 || image.Height <= 0 || - image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { - return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) + if image.Kind == MediaKindImage { + images++ + if image.Width <= 0 || image.Height <= 0 || + image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { + return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) + } + } else { + videos++ + if image.Width < 0 || image.Height < 0 || + image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { + return fmt.Errorf("%w: invalid video dimensions", ErrInvalidPost) + } } if ids[image.ID] || keys[image.ObjectKey] { return fmt.Errorf("%w: duplicate image", ErrInvalidPost) @@ -242,6 +263,12 @@ func preparePostImages(p *Post) error { image.CreatedAt = now } } + if images > MaxPostImages { + return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages) + } + if videos > MaxPostVideos { + return fmt.Errorf("%w: at most %d video is allowed", ErrInvalidPost, MaxPostVideos) + } return nil } @@ -253,6 +280,7 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage) ObjectKey: image.ObjectKey, PublicUrl: image.PublicURL, Description: image.Description, + Kind: image.Kind, Position: int16(image.Position), Width: int32(image.Width), Height: int32(image.Height), @@ -271,6 +299,7 @@ func postImageFromSQL(image sqlc.PostImage) PostImage { ObjectKey: image.ObjectKey, PublicURL: image.PublicUrl, Description: image.Description, + Kind: image.Kind, Position: int(image.Position), Width: int(image.Width), Height: int(image.Height), diff --git a/internal/store/post_test.go b/internal/store/post_test.go index 5efd5fa..69ecb61 100644 --- a/internal/store/post_test.go +++ b/internal/store/post_test.go @@ -338,6 +338,36 @@ func TestMemoryPostImages(t *testing.T) { if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) { t.Fatalf("five-image create error = %v, want ErrInvalidPost", err) } + + withVideo := &Post{ + AuthorID: homeowner.ID, + Title: "With video", + Body: "Four photos and a clip.", + Images: append(validPostImages(4), PostImage{ + ID: "clip-1", + ObjectKey: "posts/clip-1.mp4", + PublicURL: "https://cdn.example/clip-1.mp4", + Kind: MediaKindVideo, + }), + } + if err := mem.CreatePost(ctx, withVideo); err != nil { + t.Fatalf("four images and one video: %v", err) + } + if withVideo.Images[4].Kind != MediaKindVideo || withVideo.Images[4].Position != 4 { + t.Fatalf("video not stored: %+v", withVideo.Images[4]) + } + twoVideos := &Post{ + AuthorID: homeowner.ID, + Title: "Two clips", + Body: "Not allowed.", + Images: []PostImage{ + {ObjectKey: "posts/a.mp4", PublicURL: "https://cdn.example/a.mp4", Kind: MediaKindVideo}, + {ObjectKey: "posts/b.mp4", PublicURL: "https://cdn.example/b.mp4", Kind: MediaKindVideo}, + }, + } + if err := mem.CreatePost(ctx, twoVideos); !errors.Is(err, ErrInvalidPost) { + t.Fatalf("two-video create error = %v, want ErrInvalidPost", err) + } } func validPostImages(count int) []PostImage { diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 560d0ce..d0fcce1 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -35,6 +35,7 @@ type PostImage struct { ObjectKey string PublicUrl string Description string + Kind string Position int16 Width int32 Height int32 diff --git a/internal/store/sqlc/posts.sql.go b/internal/store/sqlc/posts.sql.go index 7e468dd..ebb5d98 100644 --- a/internal/store/sqlc/posts.sql.go +++ b/internal/store/sqlc/posts.sql.go @@ -59,7 +59,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error { const createPostImage = `-- name: CreatePostImage :exec INSERT INTO post_images ( - id, post_id, object_key, public_url, description, position, width, height, created_at + id, post_id, object_key, public_url, description, kind, position, width, height, created_at ) VALUES ( $1, @@ -70,7 +70,8 @@ VALUES ( $6, $7, $8, - $9 + $9, + $10 ) ` @@ -80,6 +81,7 @@ type CreatePostImageParams struct { ObjectKey string PublicUrl string Description string + Kind string Position int16 Width int32 Height int32 @@ -93,6 +95,7 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams arg.ObjectKey, arg.PublicUrl, arg.Description, + arg.Kind, arg.Position, arg.Width, arg.Height, @@ -201,7 +204,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot const listPostImages = `-- name: ListPostImages :many SELECT - id, post_id, object_key, public_url, description, position, width, height, created_at + id, post_id, object_key, public_url, description, kind, position, width, height, created_at FROM post_images WHERE post_id = $1 ORDER BY position @@ -222,6 +225,7 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag &i.ObjectKey, &i.PublicUrl, &i.Description, + &i.Kind, &i.Position, &i.Width, &i.Height, @@ -327,7 +331,7 @@ WITH RECURSIVE thread AS ( ) SELECT images.id, images.post_id, images.object_key, images.public_url, - images.description, images.position, images.width, images.height, images.created_at + images.description, images.kind, images.position, images.width, images.height, images.created_at FROM post_images images JOIN thread ON thread.id = images.post_id ORDER BY images.post_id, images.position @@ -348,6 +352,7 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po &i.ObjectKey, &i.PublicUrl, &i.Description, + &i.Kind, &i.Position, &i.Width, &i.Height, diff --git a/schema.sql b/schema.sql index 4caac27..4f8006b 100644 --- a/schema.sql +++ b/schema.sql @@ -48,13 +48,23 @@ CREATE TABLE IF NOT EXISTS post_images ( object_key TEXT NOT NULL UNIQUE, public_url TEXT NOT NULL, description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500), - position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3), - width INTEGER NOT NULL CHECK (width > 0), - height INTEGER NOT NULL CHECK (height > 0), + kind TEXT NOT NULL DEFAULT 'image', + position SMALLINT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, created_at TEXT NOT NULL, + CONSTRAINT post_images_kind_check CHECK (kind IN ('image', 'video')), + CONSTRAINT post_images_width_check CHECK (width >= 0), + CONSTRAINT post_images_height_check CHECK (height >= 0), + CONSTRAINT post_images_image_dims_check CHECK (kind <> 'image' OR (width > 0 AND height > 0)), + CONSTRAINT post_images_position_check CHECK (position BETWEEN 0 AND 4), UNIQUE (post_id, position) ); +CREATE UNIQUE INDEX IF NOT EXISTS post_images_one_video_uidx + ON post_images (post_id) + WHERE kind = 'video'; + CREATE TABLE IF NOT EXISTS post_votes ( user_id TEXT NOT NULL REFERENCES users(id), post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,