Add post image storage
CI / test (pull_request) Successful in 6m17s

This commit is contained in:
2026-08-27 23:46:06 -07:00
parent c6f80e243d
commit 1840a662d9
9 changed files with 534 additions and 11 deletions
+160 -11
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
@@ -24,11 +25,26 @@ var (
type PostState string
const (
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
PostStateVisible PostState = "visible"
PostStateHidden PostState = "hidden"
PostStateLocked PostState = "locked"
MaxPostImages = 4
MaxImageDescriptionRunes = 500
)
// PostImage is one ordered public image attached to a post.
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicURL string
Description string
Position int
Width int
Height int
CreatedAt string
}
// Post is either a root question (ParentID nil) or a reply to another post.
type Post struct {
ID string
@@ -46,6 +62,7 @@ type Post struct {
Score int
Answered bool
UserVote int
Images []PostImage
Replies []*Post
db *sql.DB
}
@@ -63,7 +80,16 @@ func (p *Post) Create(ctx context.Context) error {
if err := preparePost(p); err != nil {
return err
}
err := sqlc.New(p.db).CreatePost(ctx, sqlc.CreatePostParams{
if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
if err := q.CreatePost(ctx, sqlc.CreatePostParams{
ID: p.ID,
ParentID: nullableParentID(p.ParentID),
AuthorID: p.AuthorID,
@@ -74,11 +100,16 @@ func (p *Post) Create(ctx context.Context) error {
PostState: string(p.PostState),
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
return mapPostCreateError(err)
}); err != nil {
return mapPostCreateError(err)
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
}
// Update changes only the post body and update timestamp.
// Update changes the post body, update timestamp, and complete image set.
func (p *Post) Update(ctx context.Context) error {
if p == nil || p.db == nil {
return fmt.Errorf("post: no database")
@@ -88,7 +119,16 @@ func (p *Post) Update(ctx context.Context) error {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
p.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
n, err := sqlc.New(p.db).UpdatePost(ctx, sqlc.UpdatePostParams{
if err := preparePostImages(p); err != nil {
return err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := sqlc.New(tx)
n, err := q.UpdatePost(ctx, sqlc.UpdatePostParams{
ID: p.ID,
Body: p.Body,
UpdatedAt: p.UpdatedAt,
@@ -99,7 +139,13 @@ func (p *Post) Update(ctx context.Context) error {
if n == 0 {
return sql.ErrNoRows
}
return nil
if err := q.DeletePostImages(ctx, p.ID); err != nil {
return err
}
if err := createPostImages(ctx, q, p.Images); err != nil {
return mapPostCreateError(err)
}
return tx.Commit()
}
func preparePost(p *Post) error {
@@ -153,6 +199,85 @@ func preparePost(p *Post) error {
return nil
}
func preparePostImages(p *Post) error {
if len(p.Images) > MaxPostImages {
return fmt.Errorf("%w: at most %d images are allowed", ErrInvalidPost, MaxPostImages)
}
ids := make(map[string]bool, len(p.Images))
keys := make(map[string]bool, len(p.Images))
now := time.Now().UTC().Format(time.RFC3339Nano)
for i := range p.Images {
image := &p.Images[i]
image.ID = strings.TrimSpace(image.ID)
image.PostID = strings.TrimSpace(image.PostID)
image.ObjectKey = strings.TrimSpace(image.ObjectKey)
image.PublicURL = strings.TrimSpace(image.PublicURL)
image.Description = strings.TrimSpace(image.Description)
if image.ID == "" {
image.ID = uuid.NewString()
}
if image.PostID == "" {
image.PostID = p.ID
}
if image.PostID != p.ID {
return fmt.Errorf("%w: image belongs to another post", ErrInvalidPost)
}
if image.ObjectKey == "" || image.PublicURL == "" {
return fmt.Errorf("%w: image storage metadata is required", ErrInvalidPost)
}
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 ids[image.ID] || keys[image.ObjectKey] {
return fmt.Errorf("%w: duplicate image", ErrInvalidPost)
}
ids[image.ID] = true
keys[image.ObjectKey] = true
image.Position = i
if image.CreatedAt == "" {
image.CreatedAt = now
}
}
return nil
}
func createPostImages(ctx context.Context, q *sqlc.Queries, images []PostImage) error {
for _, image := range images {
if err := q.CreatePostImage(ctx, sqlc.CreatePostImageParams{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicUrl: image.PublicURL,
Description: image.Description,
Position: int16(image.Position),
Width: int32(image.Width),
Height: int32(image.Height),
CreatedAt: image.CreatedAt,
}); err != nil {
return err
}
}
return nil
}
func postImageFromSQL(image sqlc.PostImage) PostImage {
return PostImage{
ID: image.ID,
PostID: image.PostID,
ObjectKey: image.ObjectKey,
PublicURL: image.PublicUrl,
Description: image.Description,
Position: int(image.Position),
Width: int(image.Width),
Height: int(image.Height),
CreatedAt: image.CreatedAt,
}
}
func nullableParentID(parentID *string) sql.NullString {
if parentID == nil {
return sql.NullString{}
@@ -209,7 +334,8 @@ func postFromValues(
// GetPost returns one post without loading its replies.
func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r, err := sqlc.New(db).GetPost(ctx, id)
q := sqlc.New(db)
r, err := q.GetPost(ctx, id)
if err != nil {
return nil, err
}
@@ -228,12 +354,20 @@ func GetPost(ctx context.Context, db *sql.DB, id string) (*Post, error) {
r.CreatedAt,
r.UpdatedAt,
)
imageRows, err := q.ListPostImages(ctx, id)
if err != nil {
return nil, err
}
for _, image := range imageRows {
p.Images = append(p.Images, postImageFromSQL(image))
}
return &p, nil
}
// GetPostThread returns a root post with all descendants nested under Replies.
func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error) {
rows, err := sqlc.New(db).ListPostThread(ctx, rootID)
q := sqlc.New(db)
rows, err := q.ListPostThread(ctx, rootID)
if err != nil {
return nil, err
}
@@ -255,6 +389,21 @@ func GetPostThread(ctx context.Context, db *sql.DB, rootID string) (*Post, error
r.UpdatedAt,
))
}
imageRows, err := q.ListPostThreadImages(ctx, rootID)
if err != nil {
return nil, err
}
postsByID := make(map[string]*Post, len(posts))
for i := range posts {
postsByID[posts[i].ID] = &posts[i]
}
for _, image := range imageRows {
post, ok := postsByID[image.PostID]
if !ok {
return nil, fmt.Errorf("image %s belongs to missing post %s", image.ID, image.PostID)
}
post.Images = append(post.Images, postImageFromSQL(image))
}
return buildPostTree(posts, rootID)
}