Author SHA1 Message Date
codegirl007 1840a662d9 Add post image storage
CI / test (pull_request) Successful in 6m17s
2026-08-27 23:46:06 -07:00
codegirl007 c6f80e243d Merge pull request 'App' (#9) from app into master
CI / test (push) Successful in 6m17s
Reviewed-on: #9
2026-08-27 16:25:35 +00:00
codegirl007 8a2c7de8ca Add post permalinks (#8)
CI / test (pull_request) Successful in 6m17s
Reviewed-on: #8
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:21:38 +00:00
codegirl007 f420f888af Remove legacy question storage (#7)
Deletes obsolete question/answer/vote persistence and the compatibility answer endpoint. Existing databases drop the legacy tables through migration 009. Plumber replies now notify the root homeowner even when nested beneath another plumber reply. Post and reply forms prevent duplicate submissions and show progress while posting.

Reviewed-on: #7
Co-authored-by: codegirl-007 <s.raide@gmail.com>
2026-08-27 16:17:57 +00:00
codegirl007 f33a0739c2 Initial Ask a Plumber First server 2026-08-22 12:26:32 -07:00
12 changed files with 548 additions and 11 deletions
+46
View File
@@ -61,6 +61,52 @@ SET
updated_at = sqlc.arg(updated_at)
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
)
VALUES (
sqlc.arg(id),
sqlc.arg(post_id),
sqlc.arg(object_key),
sqlc.arg(public_url),
sqlc.arg(description),
sqlc.arg(position),
sqlc.arg(width),
sqlc.arg(height),
sqlc.arg(created_at)
);
-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = sqlc.arg(post_id);
-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = sqlc.arg(post_id)
ORDER BY position;
-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = sqlc.arg(root_id) AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, 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;
-- name: UpdateRootPostState :execrows
UPDATE posts
SET
+9
View File
@@ -209,6 +209,9 @@ func (m *Memory) CreatePost(_ context.Context, post *Post) error {
if err := preparePost(post); err != nil {
return err
}
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.users[post.AuthorID]; !ok {
@@ -287,6 +290,10 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
if body == "" {
return fmt.Errorf("%w: body is required", ErrInvalidPost)
}
post.Body = body
if err := preparePostImages(post); err != nil {
return err
}
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.posts[post.ID]
@@ -294,6 +301,7 @@ func (m *Memory) UpdatePost(_ context.Context, post *Post) error {
return sql.ErrNoRows
}
existing.Body = body
existing.Images = append([]PostImage(nil), post.Images...)
existing.UpdatedAt = time.Now().UTC().Format(time.RFC3339Nano)
*post = *clonePostWithAuthor(existing, m.users)
return nil
@@ -451,6 +459,7 @@ func clonePost(post *Post) *Post {
parentID := *post.ParentID
cp.ParentID = &parentID
}
cp.Images = append([]PostImage(nil), post.Images...)
cp.Replies = nil
return &cp
}
+20
View File
@@ -120,6 +120,25 @@ func migrateDropLegacyPostTables(ctx context.Context, exec execContext) error {
return nil
}
func migratePostImages(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS post_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
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),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
)`); err != nil {
return fmt.Errorf("create post images: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -311,6 +330,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"007_post_state", migratePostState},
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
}
for _, m := range migrations {
if applied[m.version] {
+39
View File
@@ -75,6 +75,12 @@ CREATE TABLE users (
if err := migratePostAuthorIndex(ctx, conn); err != nil {
t.Fatalf("post author index migration is not idempotent: %v", err)
}
if err := migratePostImages(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migratePostImages(ctx, conn); err != nil {
t.Fatalf("post images 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');
@@ -104,6 +110,39 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
if postCount != 2 || voteCount != 1 {
t.Fatalf("counts posts=%d votes=%d", postCount, voteCount)
}
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"},
} {
if err := imageQueries.CreatePostImage(ctx, image); err != nil {
t.Fatal(err)
}
}
rootImages, err := imageQueries.ListPostImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(rootImages) != 2 ||
rootImages[0].ID != "root-image-1" ||
rootImages[1].ID != "root-image-2" {
t.Fatalf("root images = %+v", rootImages)
}
threadImages, err := imageQueries.ListPostThreadImages(ctx, "root-1")
if err != nil {
t.Fatal(err)
}
if len(threadImages) != 3 {
t.Fatalf("thread images = %+v", threadImages)
}
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,
Width: 100, Height: 100, CreatedAt: "2026-08-26T08:00:00Z",
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
+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)
}
+87
View File
@@ -267,6 +267,93 @@ func TestMemoryPostValidation(t *testing.T) {
}
}
func TestMemoryPostImages(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "images", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
root := &Post{
ID: "image-root",
AuthorID: homeowner.ID,
Title: "What is leaking?",
Body: "Here are two photos.",
Images: []PostImage{
{ID: "image-a", ObjectKey: "posts/image-root/image-a.jpg", PublicURL: "https://cdn.example/image-a.jpg", Description: " Supply valve ", Position: 3, Width: 1200, Height: 900},
{ID: "image-b", ObjectKey: "posts/image-root/image-b.png", PublicURL: "https://cdn.example/image-b.png", Width: 900, Height: 1200},
},
}
if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err)
}
if root.Images[0].Position != 0 ||
root.Images[1].Position != 1 ||
root.Images[0].PostID != root.ID ||
root.Images[0].Description != "Supply valve" {
t.Fatalf("created images were not normalized: %+v", root.Images)
}
loaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
loaded.Images[0].Description = "mutated outside store"
reloaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.Images[0].Description != "Supply valve" {
t.Fatalf("stored image mutated through clone: %+v", reloaded.Images[0])
}
reloaded.Body = "Updated photos."
reloaded.Images = []PostImage{
reloaded.Images[1],
{ID: "image-c", ObjectKey: "posts/image-root/image-c.jpg", PublicURL: "https://cdn.example/image-c.jpg", Description: "Trap connection", Width: 1600, Height: 1000},
}
if err := mem.UpdatePost(ctx, reloaded); err != nil {
t.Fatal(err)
}
updated, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if len(updated.Images) != 2 ||
updated.Images[0].ID != "image-b" ||
updated.Images[0].Position != 0 ||
updated.Images[1].ID != "image-c" ||
updated.Images[1].Position != 1 {
t.Fatalf("updated images = %+v", updated.Images)
}
tooMany := &Post{
AuthorID: homeowner.ID,
Title: "Too many",
Body: "Five photos.",
Images: validPostImages(5),
}
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
images := make([]PostImage, count)
for i := range images {
images[i] = PostImage{
ID: "image-" + string(rune('a'+i)),
ObjectKey: "posts/key-" + string(rune('a'+i)) + ".jpg",
PublicURL: "https://cdn.example/" + string(rune('a'+i)) + ".jpg",
Width: 100,
Height: 100,
}
}
return images
}
func ptr(value string) *string {
return &value
}
+12
View File
@@ -22,6 +22,18 @@ type Post struct {
UpdatedAt string
}
type PostImage struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
type PostVote struct {
UserID string
PostID string
+148
View File
@@ -57,6 +57,60 @@ func (q *Queries) CreatePost(ctx context.Context, arg CreatePostParams) error {
return err
}
const createPostImage = `-- name: CreatePostImage :exec
INSERT INTO post_images (
id, post_id, object_key, public_url, description, position, width, height, created_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
`
type CreatePostImageParams struct {
ID string
PostID string
ObjectKey string
PublicUrl string
Description string
Position int16
Width int32
Height int32
CreatedAt string
}
func (q *Queries) CreatePostImage(ctx context.Context, arg CreatePostImageParams) error {
_, err := q.db.ExecContext(ctx, createPostImage,
arg.ID,
arg.PostID,
arg.ObjectKey,
arg.PublicUrl,
arg.Description,
arg.Position,
arg.Width,
arg.Height,
arg.CreatedAt,
)
return err
}
const deletePostImages = `-- name: DeletePostImages :exec
DELETE FROM post_images
WHERE post_id = $1
`
func (q *Queries) DeletePostImages(ctx context.Context, postID string) error {
_, err := q.db.ExecContext(ctx, deletePostImages, postID)
return err
}
const deletePostVote = `-- name: DeletePostVote :exec
DELETE FROM post_votes
WHERE user_id = $1
@@ -145,6 +199,47 @@ func (q *Queries) GetRootPostVoteSummary(ctx context.Context, arg GetRootPostVot
return i, err
}
const listPostImages = `-- name: ListPostImages :many
SELECT
id, post_id, object_key, public_url, description, position, width, height, created_at
FROM post_images
WHERE post_id = $1
ORDER BY position
`
func (q *Queries) ListPostImages(ctx context.Context, postID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostImages, postID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPostThread = `-- name: ListPostThread :many
WITH RECURSIVE thread AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
@@ -218,6 +313,59 @@ func (q *Queries) ListPostThread(ctx context.Context, rootID string) ([]ListPost
return items, nil
}
const listPostThreadImages = `-- name: ListPostThreadImages :many
WITH RECURSIVE thread AS (
SELECT p.id
FROM posts p
WHERE p.id = $1 AND p.parent_id IS NULL
UNION ALL
SELECT child.id
FROM posts child
JOIN thread parent ON child.parent_id = parent.id
)
SELECT
images.id, images.post_id, images.object_key, images.public_url,
images.description, 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
`
func (q *Queries) ListPostThreadImages(ctx context.Context, rootID string) ([]PostImage, error) {
rows, err := q.db.QueryContext(ctx, listPostThreadImages, rootID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []PostImage{}
for rows.Next() {
var i PostImage
if err := rows.Scan(
&i.ID,
&i.PostID,
&i.ObjectKey,
&i.PublicUrl,
&i.Description,
&i.Position,
&i.Width,
&i.Height,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootPosts = `-- name: ListRootPosts :many
WITH RECURSIVE roots AS (
SELECT p.id, p.parent_id, p.author_id, p.title, p.body, p.city, p.post_date, p.post_state, p.created_at, p.updated_at
+6
View File
@@ -462,6 +462,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
`data-submit-button`,
`action="/posts/` + root.ID + `/edit"`,
`action="/posts/` + homeownerReply.ID + `/edit"`,
`href="/questions/` + root.ID + `#post-` + root.ID + `"`,
`href="/questions/` + root.ID + `#post-` + homeownerReply.ID + `"`,
`href="/questions/` + root.ID + `#post-` + adminReply.ID + `"`,
`>The model number is 123A.</textarea>`,
`removeAttribute('open')`,
} {
@@ -469,6 +472,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) {
t.Fatalf("question page missing %q: %s", want, body)
}
}
if got := strings.Count(body, ">Permalink</a>"); got != 3 {
t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body)
}
if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) {
t.Fatalf("homeowner can edit admin reply: %s", body)
}
+13
View File
@@ -42,6 +42,19 @@ CREATE INDEX IF NOT EXISTS idx_posts_root_date
ON posts(post_date, post_state)
WHERE parent_id IS NULL;
CREATE TABLE IF NOT EXISTS post_images (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
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),
created_at TEXT NOT NULL,
UNIQUE (post_id, position)
);
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,
+5
View File
@@ -749,6 +749,11 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:
.post-form-actions .btn { flex: 1 1 10rem; }
.post-permalink {
display: inline-flex;
align-items: center;
}
.post-hide {
margin: 0;
}
+3
View File
@@ -33,6 +33,9 @@
</form>
</details>
{{end}}
<a class="linkish post-permalink"
href="/questions/{{.Root.ID}}#post-{{.Post.ID}}"
aria-label="Permanent link to post by {{.Post.AuthorName}}">Permalink</a>
{{if and (not .Post.ParentID) (isAdmin .User)}}
<form class="post-hide" method="post" action="/questions/{{.Post.ID}}/hide">
<input type="hidden" name="_csrf" value="{{.CSRF}}">