Add post video storage #21

Merged
codegirl007 merged 1 commits from post-video-storage into master 2026-08-31 04:37:09 +00:00
8 changed files with 144 additions and 21 deletions
+4 -3
View File
@@ -63,7 +63,7 @@ WHERE id = sqlc.arg(id);
-- name: CreatePostImage :exec -- name: CreatePostImage :exec
INSERT INTO post_images ( 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 ( VALUES (
sqlc.arg(id), sqlc.arg(id),
@@ -71,6 +71,7 @@ VALUES (
sqlc.arg(object_key), sqlc.arg(object_key),
sqlc.arg(public_url), sqlc.arg(public_url),
sqlc.arg(description), sqlc.arg(description),
sqlc.arg(kind),
sqlc.arg(position), sqlc.arg(position),
sqlc.arg(width), sqlc.arg(width),
sqlc.arg(height), sqlc.arg(height),
@@ -83,7 +84,7 @@ WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many -- name: ListPostImages :many
SELECT 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 FROM post_images
WHERE post_id = sqlc.arg(post_id) WHERE post_id = sqlc.arg(post_id)
ORDER BY position; ORDER BY position;
@@ -102,7 +103,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, 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 FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position; ORDER BY images.post_id, images.position;
+27
View File
@@ -158,6 +158,32 @@ CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
return nil 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 { func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct { steps := []struct {
name string name string
@@ -351,6 +377,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables}, {"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages}, {"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks}, {"011_discord_post_links", migrateDiscordPostLinks},
{"012_post_image_video", migratePostImageVideo},
} }
for _, m := range migrations { for _, m := range migrations {
if applied[m.version] { if applied[m.version] {
+25 -5
View File
@@ -87,6 +87,12 @@ CREATE TABLE users (
if err := migrateDiscordPostLinks(ctx, conn); err != nil { if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err) 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, ` if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role) INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -118,9 +124,9 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
imageQueries := sqlc.New(conn) imageQueries := sqlc.New(conn)
for _, image := range []sqlc.CreatePostImageParams{ 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-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", Position: 1, Width: 900, Height: 1200, 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", Position: 0, Width: 1000, Height: 1000, CreatedAt: "2026-08-26T09: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 { if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -144,10 +150,24 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
} }
if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{ if err := imageQueries.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: "too-many", PostID: "root-1", ObjectKey: "posts/root-1/5.jpg", 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", Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil { }); 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{ if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1", PostID: "root-1",
+32 -3
View File
@@ -29,16 +29,20 @@ const (
PostStateHidden PostState = "hidden" PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked" PostStateLocked PostState = "locked"
MaxPostImages = 4 MaxPostImages = 4
MaxPostVideos = 1
MaxImageDescriptionRunes = 500 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 { type PostImage struct {
ID string ID string
PostID string PostID string
ObjectKey string ObjectKey string
PublicURL string PublicURL string
Description string Description string
Kind string
Position int Position int
Width int Width int
Height int Height int
@@ -200,12 +204,13 @@ func preparePost(p *Post) error {
} }
func preparePostImages(p *Post) error { func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages { if len(p.Images) > MaxPostImages+MaxPostVideos {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages) return fmt.Errorf("%w: at most %d images and %d video are allowed", ErrInvalidPost, MaxPostImages, MaxPostVideos)
} }
ids := make(map[string]bool, len(p.Images)) ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images)) keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano) now := time.Now().UTC().Format(time.RFC3339Nano)
images, videos := 0, 0
for i := range p.Images { for i := range p.Images {
image := &p.Images[i] image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID) image.ID = strings.TrimSpace(image.ID)
@@ -213,6 +218,13 @@ func preparePostImages(p *Post) error {
image.ObjectKey = strings.TrimSpace(image.ObjectKey) image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL) image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description) 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 == "" { if image.ID == "" {
image.ID = uuid.NewString() image.ID = uuid.NewString()
} }
@@ -228,10 +240,19 @@ func preparePostImages(p *Post) error {
if len([]rune(image.Description)) > MaxImageDescriptionRunes { if len([]rune(image.Description)) > MaxImageDescriptionRunes {
return fmt.Errorf("%w: image description is too long", ErrInvalidPost) return fmt.Errorf("%w: image description is too long", ErrInvalidPost)
} }
if image.Kind == MediaKindImage {
images++
if image.Width <= 0 || image.Height <= 0 || if image.Width <= 0 || image.Height <= 0 ||
image.Width > math.MaxInt32 || image.Height > math.MaxInt32 { image.Width > math.MaxInt32 || image.Height > math.MaxInt32 {
return fmt.Errorf("%w: invalid image dimensions", ErrInvalidPost) 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] { if ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost) return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
} }
@@ -242,6 +263,12 @@ func preparePostImages(p *Post) error {
image.CreatedAt = now 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 return nil
} }
@@ -253,6 +280,7 @@ func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage)
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL, PublicUrl: image.PublicURL,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int16(image.Position), Position: int16(image.Position),
Width: int32(image.Width), Width: int32(image.Width),
Height: int32(image.Height), Height: int32(image.Height),
@@ -271,6 +299,7 @@ func postImageFromSQL(image sqlc.PostImage) PostImage {
ObjectKey: image.ObjectKey, ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl, PublicURL: image.PublicUrl,
Description: image.Description, Description: image.Description,
Kind: image.Kind,
Position: int(image.Position), Position: int(image.Position),
Width: int(image.Width), Width: int(image.Width),
Height: int(image.Height), Height: int(image.Height),
+30
View File
@@ -338,6 +338,36 @@ func TestMemoryPostImages(t *testing.T) {
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) { if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err) 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 { func validPostImages(count int) []PostImage {
+1
View File
@@ -35,6 +35,7 @@ type PostImage struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height int32 Height int32
+9 -4
View File
@@ -59,7 +59,7 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
const createPostImage = `-- name: CreatePostImage :exec const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images ( 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 ( VALUES (
$1, $1,
@@ -70,7 +70,8 @@ VALUES (
$6, $6,
$7, $7,
$8, $8,
$9 $9,
$10
) )
` `
@@ -80,6 +81,7 @@ type CreatePostImageParams struct {
ObjectKey string ObjectKey string
PublicUrl string PublicUrl string
Description string Description string
Kind string
Position int16 Position int16
Width int32 Width int32
Height int32 Height int32
@@ -93,6 +95,7 @@ func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams
arg.ObjectKey, arg.ObjectKey,
arg.PublicUrl, arg.PublicUrl,
arg.Description, arg.Description,
arg.Kind,
arg.Position, arg.Position,
arg.Width, arg.Width,
arg.Height, arg.Height,
@@ -201,7 +204,7 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
const listPostImages = `-- name: ListPostImages :many const listPostImages = `-- name: ListPostImages :many
SELECT 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 FROM post_images
WHERE post_id = $1 WHERE post_id = $1
ORDER BY position ORDER BY position
@@ -222,6 +225,7 @@ func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImag
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
@@ -327,7 +331,7 @@ WITH RECURSIVE thread AS (
) )
SELECT SELECT
images.id, images.post_id, images.object_key, images.public_url, 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 FROM post_images images
JOIN thread ON thread.id = images.post_id JOIN thread ON thread.id = images.post_id
ORDER BY images.post_id, images.position ORDER BY images.post_id, images.position
@@ -348,6 +352,7 @@ func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]Po
&i.ObjectKey, &i.ObjectKey,
&i.PublicUrl, &i.PublicUrl,
&i.Description, &i.Description,
&i.Kind,
&i.Position, &i.Position,
&i.Width, &i.Width,
&i.Height, &i.Height,
+13 -3
View File
@@ -48,13 +48,23 @@ CREATE TABLE IF NOT EXISTS post_images (
object_key TEXT NOT NULL UNIQUE, object_key TEXT NOT NULL UNIQUE,
public_url TEXT NOT NULL, public_url TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500), description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 500),
position SMALLINT NOT NULL CHECK (position BETWEEN 0 AND 3), kind TEXT NOT NULL DEFAULT 'image',
width INTEGER NOT NULL CHECK (width > 0), position SMALLINT NOT NULL,
height INTEGER NOT NULL CHECK (height > 0), width INTEGER NOT NULL,
height INTEGER NOT NULL,
created_at TEXT 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) 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 ( CREATE TABLE IF NOT EXISTS post_votes (
user_id TEXT NOT NULL REFERENCES users(id), user_id TEXT NOT NULL REFERENCES users(id),
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE, post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,