+160
-11
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user