Address follow-up review: cheaper avatars, list limits, less chatter.
Switch avatar resize to ApproxBiLinear, cap hunt/profile/admin list queries, drop redundant admin/profile lookups, dedupe CI on app PRs, and refresh stale todo.md notes.
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [app, master, main]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [master, main]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ LEFT JOIN votes v ON v.question_id = q.id
|
|||||||
LEFT JOIN answers a ON a.question_id = q.id
|
LEFT JOIN answers a ON a.question_id = q.id
|
||||||
WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
|
WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
|
||||||
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
||||||
ORDER BY score DESC, q.created_at ASC;
|
ORDER BY score DESC, q.created_at ASC
|
||||||
|
LIMIT sqlc.arg(row_limit);
|
||||||
|
|
||||||
-- name: GetQuestion :one
|
-- name: GetQuestion :one
|
||||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||||
@@ -45,7 +46,8 @@ FROM questions q
|
|||||||
JOIN users u ON u.id = q.author_id
|
JOIN users u ON u.id = q.author_id
|
||||||
LEFT JOIN answers a ON a.question_id = q.id
|
LEFT JOIN answers a ON a.question_id = q.id
|
||||||
WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
|
WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
|
||||||
ORDER BY q.created_at DESC;
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT sqlc.arg(row_limit);
|
||||||
|
|
||||||
-- name: ListQuestionsAnsweredBy :many
|
-- name: ListQuestionsAnsweredBy :many
|
||||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||||
@@ -56,4 +58,5 @@ FROM answers ans
|
|||||||
JOIN questions q ON q.id = ans.question_id
|
JOIN questions q ON q.id = ans.question_id
|
||||||
JOIN users u ON u.id = q.author_id
|
JOIN users u ON u.id = q.author_id
|
||||||
WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
|
WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
|
||||||
ORDER BY ans.updated_at DESC;
|
ORDER BY ans.updated_at DESC
|
||||||
|
LIMIT sqlc.arg(row_limit);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ WHERE username = $1;
|
|||||||
-- name: ListUsers :many
|
-- name: ListUsers :many
|
||||||
SELECT id, username, name, role, avatar_url, state, created_at
|
SELECT id, username, name, role, avatar_url, state, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at ASC;
|
ORDER BY created_at ASC
|
||||||
|
LIMIT sqlc.arg(row_limit);
|
||||||
|
|
||||||
-- name: CountAdmins :one
|
-- name: CountAdmins :one
|
||||||
SELECT COUNT(*)::bigint AS count
|
SELECT COUNT(*)::bigint AS count
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ func (m *Memory) ListUsers(_ context.Context) ([]User, error) {
|
|||||||
out = append(out, *u)
|
out = append(out, *u)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||||
|
if len(out) > AdminUsersLimit {
|
||||||
|
out = out[:AdminUsersLimit]
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +226,9 @@ func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]Ranke
|
|||||||
}
|
}
|
||||||
return out[i].CreatedAt < out[j].CreatedAt
|
return out[i].CreatedAt < out[j].CreatedAt
|
||||||
})
|
})
|
||||||
|
if len(out) > HuntListLimit {
|
||||||
|
out = out[:HuntListLimit]
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +243,9 @@ func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]Ra
|
|||||||
out = append(out, m.annotate(q, ""))
|
out = append(out, m.annotate(q, ""))
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||||
|
if len(out) > ProfileListLimit {
|
||||||
|
out = out[:ProfileListLimit]
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +266,9 @@ func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]R
|
|||||||
out = append(out, rq)
|
out = append(out, rq)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||||
|
if len(out) > ProfileListLimit {
|
||||||
|
out = out[:ProfileListLimit]
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeUsername(t *testing.T) {
|
func TestNormalizeUsername(t *testing.T) {
|
||||||
if got := NormalizeUsername(" Alice_1 "); got != "alice_1" {
|
if got := NormalizeUsername(" Alice_1 "); got != "alice_1" {
|
||||||
@@ -14,20 +17,7 @@ func TestPostgresDSNDefaultsSSLMode(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !containsAny(out, "sslmode=verify-full") {
|
if !strings.Contains(out, "sslmode=verify-full") {
|
||||||
t.Fatalf("missing default sslmode: %s", out)
|
t.Fatalf("missing default sslmode: %s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func containsAny(s string, parts ...string) bool {
|
|
||||||
for _, p := range parts {
|
|
||||||
if len(p) > 0 && (len(s) >= len(p)) {
|
|
||||||
for i := 0; i+len(p) <= len(s); i++ {
|
|
||||||
if s[i:i+len(p)] == p {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]Ran
|
|||||||
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
|
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
|
||||||
ViewerID: viewerID,
|
ViewerID: viewerID,
|
||||||
HuntDate: huntDate,
|
HuntDate: huntDate,
|
||||||
|
RowLimit: HuntListLimit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -125,7 +126,10 @@ func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQ
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
|
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
|
||||||
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, authorID)
|
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{
|
||||||
|
AuthorID: authorID,
|
||||||
|
RowLimit: ProfileListLimit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,10 @@ func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
|
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
|
||||||
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, adminID)
|
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{
|
||||||
|
AdminID: adminID,
|
||||||
|
RowLimit: ProfileListLimit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,13 @@ LEFT JOIN answers a ON a.question_id = q.id
|
|||||||
WHERE q.hunt_date = $2 AND q.hidden = 0
|
WHERE q.hunt_date = $2 AND q.hidden = 0
|
||||||
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
||||||
ORDER BY score DESC, q.created_at ASC
|
ORDER BY score DESC, q.created_at ASC
|
||||||
|
LIMIT $3
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListHuntParams struct {
|
type ListHuntParams struct {
|
||||||
ViewerID string
|
ViewerID string
|
||||||
HuntDate string
|
HuntDate string
|
||||||
|
RowLimit int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListHuntRow struct {
|
type ListHuntRow struct {
|
||||||
@@ -140,7 +142,7 @@ type ListHuntRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
|
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate)
|
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -185,8 +187,14 @@ JOIN questions q ON q.id = ans.question_id
|
|||||||
JOIN users u ON u.id = q.author_id
|
JOIN users u ON u.id = q.author_id
|
||||||
WHERE ans.author_id = $1 AND q.hidden = 0
|
WHERE ans.author_id = $1 AND q.hidden = 0
|
||||||
ORDER BY ans.updated_at DESC
|
ORDER BY ans.updated_at DESC
|
||||||
|
LIMIT $2
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type ListQuestionsAnsweredByParams struct {
|
||||||
|
AdminID string
|
||||||
|
RowLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
type ListQuestionsAnsweredByRow struct {
|
type ListQuestionsAnsweredByRow struct {
|
||||||
ID string
|
ID string
|
||||||
AuthorID string
|
AuthorID string
|
||||||
@@ -202,8 +210,8 @@ type ListQuestionsAnsweredByRow struct {
|
|||||||
UserVote int64
|
UserVote int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]ListQuestionsAnsweredByRow, error) {
|
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, adminID)
|
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -248,8 +256,14 @@ JOIN users u ON u.id = q.author_id
|
|||||||
LEFT JOIN answers a ON a.question_id = q.id
|
LEFT JOIN answers a ON a.question_id = q.id
|
||||||
WHERE q.author_id = $1 AND q.hidden = 0
|
WHERE q.author_id = $1 AND q.hidden = 0
|
||||||
ORDER BY q.created_at DESC
|
ORDER BY q.created_at DESC
|
||||||
|
LIMIT $2
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type ListQuestionsByAuthorParams struct {
|
||||||
|
AuthorID string
|
||||||
|
RowLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
type ListQuestionsByAuthorRow struct {
|
type ListQuestionsByAuthorRow struct {
|
||||||
ID string
|
ID string
|
||||||
AuthorID string
|
AuthorID string
|
||||||
@@ -265,8 +279,8 @@ type ListQuestionsByAuthorRow struct {
|
|||||||
UserVote int64
|
UserVote int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]ListQuestionsByAuthorRow, error) {
|
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, authorID)
|
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ const listUsers = `-- name: ListUsers :many
|
|||||||
SELECT id, username, name, role, avatar_url, state, created_at
|
SELECT id, username, name, role, avatar_url, state, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
|
LIMIT $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListUsersRow struct {
|
type ListUsersRow struct {
|
||||||
@@ -142,8 +143,8 @@ type ListUsersRow struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
func (q *Queries) ListUsers(ctx context.Context, rowLimit int32) ([]ListUsersRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listUsers)
|
rows, err := q.db.QueryContext(ctx, listUsers, rowLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ package store
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
|
// List row caps keep hunt/profile/admin pages bounded.
|
||||||
|
const (
|
||||||
|
HuntListLimit = 100
|
||||||
|
ProfileListLimit = 50
|
||||||
|
AdminUsersLimit = 200
|
||||||
|
)
|
||||||
|
|
||||||
// Store is the application persistence API used by the web layer.
|
// Store is the application persistence API used by the web layer.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateUser(ctx context.Context, u *User) error
|
CreateUser(ctx context.Context, u *User) error
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
|
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
|
||||||
rows, err := sqlc.New(db).ListUsers(ctx)
|
rows, err := sqlc.New(db).ListUsers(ctx, AdminUsersLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
role := store.Role(r.PostFormValue("role"))
|
role := store.Role(r.PostFormValue("role"))
|
||||||
_, err := s.store.UserByID(r.Context(), id)
|
err := s.store.SetUserRole(r.Context(), id, role)
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "could not update role", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
err = s.store.SetUserRole(r.Context(), id, role)
|
|
||||||
if errors.Is(err, store.ErrLastAdmin) {
|
if errors.Is(err, store.ErrLastAdmin) {
|
||||||
users, listErr := s.store.ListUsers(r.Context())
|
users, listErr := s.store.ListUsers(r.Context())
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ func fitAvatar(img image.Image, maxDim int) image.Image {
|
|||||||
nh = 1
|
nh = 1
|
||||||
}
|
}
|
||||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,9 +229,6 @@ func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.
|
|||||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil {
|
|
||||||
u = fresh
|
|
||||||
}
|
|
||||||
p := s.basePage(r, "Profile")
|
p := s.basePage(r, "Profile")
|
||||||
p.User = u
|
p.User = u
|
||||||
s.exec(w, "profile", profilePage{
|
s.exec(w, "profile", profilePage{
|
||||||
|
|||||||
@@ -2,31 +2,30 @@
|
|||||||
|
|
||||||
From the project review. Priority order within each section.
|
From the project review. Priority order within each section.
|
||||||
|
|
||||||
## Fix soon
|
## Done recently
|
||||||
|
|
||||||
- [x] **Persist sessions** — Sessions live in the app DB (`sessions` table) via `postgresstore`. Opaque cookie unchanged; unused `SESSION_SECRET` removed from config / `.env.example`.
|
- [x] **Persist sessions** — Custom sqlc-backed `SessionStore` (scs API kept; no `postgresstore`).
|
||||||
- [x] **Drop Dockerfile** — Deploying on DigitalOcean App Platform (buildpack from `go.mod`); no container image needed.
|
- [x] **Drop Dockerfile** — DigitalOcean App Platform buildpack from `go.mod`.
|
||||||
- [x] **Rune-safe truncation** — `title[:120]`, `body[:8000]`, `city[:80]`, answer body, etc. can split multi-byte UTF-8. Truncate by runes (or safely).
|
- [x] **Rune-safe truncation** — Form fields truncate by runes.
|
||||||
- [x] **Admin bootstrap** — `ADMIN_USERNAME` seeds the first admin on register only when no admin exists. Promote/demote via `/admin/users` (admins only); roles stay in `users.role`.
|
- [x] **Admin bootstrap** — `ADMIN_USERNAME` seeds first admin only when none exist; `/admin/users` for promote/demote.
|
||||||
|
- [x] **Graceful shutdown** — Signal-aware `http.Server.Shutdown` with timeouts.
|
||||||
|
- [x] **Handler tests** — Vote HTMX, answer/hide, CSRF, session rotation via in-memory `Store` (no Postgres for web suite).
|
||||||
|
- [x] **App Platform listen port** — Prefers `PORT`, then `LISTEN`, then `:8080`.
|
||||||
|
- [x] **Prod DB = PlanetScale Postgres** — Required `DATABASE_URL`; DSN cleanup for PlanetScale/libpq-only params.
|
||||||
|
|
||||||
## Docs & ops
|
## Docs & ops
|
||||||
|
|
||||||
- [ ] **README** — How to run locally, env vars (from `.env.example`), admin bootstrap, PlanetScale `DATABASE_URL`, App Platform notes (`PORT`, `SECURE_COOKIE=1`).
|
- [ ] **README** — How to run locally, env vars (from `.env.example`), admin bootstrap, PlanetScale `DATABASE_URL`, App Platform notes (`PORT`, `SECURE_COOKIE=1`).
|
||||||
- [ ] **Migrations story** — Schema is applied on boot from `schema.sql` (+ sessions DDL). OK for v1; plan real migrations before schema drifts.
|
- [ ] **Migrations story** — Schema is applied on boot from `schema.sql`. OK for v1; plan real migrations before schema drifts.
|
||||||
- [x] **App Platform listen port** — Prefers `PORT`, then `LISTEN`, then `:8080`.
|
|
||||||
- [x] **Prod DB = PlanetScale Postgres** — App opens Postgres via required `DATABASE_URL`; DSN cleanup strips PlanetScale/libpq-only params (`sslrootcert=system`, `sslnegotiation`). Use dashboard URI on **5432** for boot schema create; **6432** (PgBouncer) later if you need pooling.
|
|
||||||
|
|
||||||
## Smaller / later
|
## Smaller / later
|
||||||
|
|
||||||
- [ ] Rate-limit login/register (bcrypt helps; still open to brute-force).
|
- [ ] Rate-limit login/register (bcrypt helps; still open to brute-force).
|
||||||
- [ ] Graceful shutdown instead of bare `ListenAndServe`.
|
- [ ] Cursor pagination UI when hunt/profile lists hit their row limits.
|
||||||
- [ ] More tests: vote HTMX paths, admin answer/hide, archive redirects; optional Postgres integration test.
|
- [ ] Optional Postgres integration tests (`TEST_DATABASE_URL`) for sqlc SessionStore / advisory locks.
|
||||||
|
|
||||||
## Suggested order of attack
|
## Suggested order of attack
|
||||||
|
|
||||||
1. ~~Persist sessions~~ done.
|
1. Short README (run, env, admin, App Platform + PlanetScale).
|
||||||
2. ~~Drop Dockerfile~~ done (App Platform).
|
2. Migrations plan before the next schema change.
|
||||||
3. ~~Wire `PORT`~~ done.
|
3. Rate-limit auth endpoints.
|
||||||
4. ~~Admin roles page~~ done.
|
|
||||||
5. Short README (run, env, admin, App Platform + PlanetScale).
|
|
||||||
6. Rune-safe truncation + a couple of handler tests (vote, admin hide).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user