frontend-foundation: GOAT front-end Phase 1 — auth, tag management, public tag page (22/22 spec scenarios pass)

This commit is contained in:
2026-08-05 13:46:50 +10:00
parent b846c2c58e
commit 133e375b6b
31 changed files with 1591 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
// Package auth provides password hashing and session handling for WhereWoof.
package auth
import "golang.org/x/crypto/bcrypt"
// HashPassword returns a bcrypt hash of the given plaintext password.
func HashPassword(password string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// CheckPassword reports whether the plaintext password matches the bcrypt hash.
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@@ -0,0 +1,51 @@
package auth
import (
"net/http"
"github.com/gorilla/sessions"
)
// SessionName is the cookie name for authenticated sessions.
const SessionName = "ww_session"
// Store is the signed + encrypted cookie store, initialised by InitSessionStore.
var Store *sessions.CookieStore
// InitSessionStore creates the cookie store with the server secret.
func InitSessionStore(secret string) {
Store = sessions.NewCookieStore([]byte(secret))
Store.Options = &sessions.Options{
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 30 * 24 * 3600, // 30 days
}
}
// GetUserID returns the authenticated user id from the request, if any.
func GetUserID(r *http.Request) (int64, bool) {
if Store == nil {
return 0, false
}
s, err := Store.Get(r, SessionName)
if err != nil {
return 0, false
}
id, ok := s.Values["user_id"].(int64)
return id, ok
}
// SetUserID records the user id in the session cookie.
func SetUserID(w http.ResponseWriter, r *http.Request, id int64) error {
s, _ := Store.Get(r, SessionName)
s.Values["user_id"] = id
return s.Save(r, w)
}
// Clear destroys the session (logout).
func Clear(w http.ResponseWriter, r *http.Request) error {
s, _ := Store.Get(r, SessionName)
s.Options.MaxAge = -1
return s.Save(r, w)
}

View File

@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}

View File

@@ -0,0 +1,44 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"github.com/jackc/pgx/v5/pgtype"
)
type Scan struct {
ID int64 `json:"id"`
TagID int64 `json:"tag_id"`
ScannedAt pgtype.Timestamptz `json:"scanned_at"`
Lat pgtype.Float8 `json:"lat"`
Lng pgtype.Float8 `json:"lng"`
LocationShared bool `json:"location_shared"`
ScannerPhone pgtype.Text `json:"scanner_phone"`
AlertSent bool `json:"alert_sent"`
}
type Tag struct {
ID int64 `json:"id"`
TagCode string `json:"tag_code"`
OwnerID pgtype.Int8 `json:"owner_id"`
Status string `json:"status"`
ItemType pgtype.Text `json:"item_type"`
Description pgtype.Text `json:"description"`
PhotoUrl pgtype.Text `json:"photo_url"`
Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type User struct {
ID int64 `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}

View File

@@ -0,0 +1,28 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
type Querier interface {
BindTag(ctx context.Context, arg BindTagParams) (Tag, error)
ClearTagOwner(ctx context.Context, id int64) (Tag, error)
CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error)
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetTagByCode(ctx context.Context, tagCode string) (Tag, error)
GetTagByID(ctx context.Context, id int64) (Tag, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
GetUserByID(ctx context.Context, id int64) (User, error)
InsertTag(ctx context.Context, tagCode string) (Tag, error)
ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error)
SetTagStatus(ctx context.Context, arg SetTagStatusParams) error
UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error)
}
var _ Querier = (*Queries)(nil)

View File

@@ -0,0 +1,47 @@
-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: ListTagsByOwner :many
SELECT * FROM tags WHERE owner_id = $1 ORDER BY created_at DESC;
-- name: GetTagByCode :one
SELECT * FROM tags WHERE tag_code = $1;
-- name: GetTagByID :one
SELECT * FROM tags WHERE id = $1;
-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1;
-- name: BindTag :one
UPDATE tags
SET owner_id = $1, updated_at = now()
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
RETURNING *;
-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
WHERE id=$1
RETURNING *;
-- name: ClearTagOwner :one
UPDATE tags
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
WHERE id=$1
RETURNING *;
-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1;
-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1)
RETURNING *;

View File

@@ -0,0 +1,319 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: queries.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const bindTag = `-- name: BindTag :one
UPDATE tags
SET owner_id = $1, updated_at = now()
WHERE tag_code = $2 AND owner_id IS NULL AND status = 'unset'
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
type BindTagParams struct {
OwnerID pgtype.Int8 `json:"owner_id"`
TagCode string `json:"tag_code"`
}
func (q *Queries) BindTag(ctx context.Context, arg BindTagParams) (Tag, error) {
row := q.db.QueryRow(ctx, bindTag, arg.OwnerID, arg.TagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const clearTagOwner = `-- name: ClearTagOwner :one
UPDATE tags
SET owner_id=NULL, status='unset', item_type=NULL, description=NULL, photo_url=NULL, phone=NULL, address=NULL, notes=NULL, updated_at=now()
WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
func (q *Queries) ClearTagOwner(ctx context.Context, id int64) (Tag, error) {
row := q.db.QueryRow(ctx, clearTagOwner, id)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const countTagsByOwner = `-- name: CountTagsByOwner :one
SELECT count(*) FROM tags WHERE owner_id = $1
`
func (q *Queries) CountTagsByOwner(ctx context.Context, ownerID pgtype.Int8) (int64, error) {
row := q.db.QueryRow(ctx, countTagsByOwner, ownerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createUser = `-- name: CreateUser :one
INSERT INTO users (email, password_hash, name, phone)
VALUES ($1, $2, $3, $4)
RETURNING id, email, password_hash, name, phone, created_at
`
type CreateUserParams struct {
Email string `json:"email"`
PasswordHash string `json:"password_hash"`
Name pgtype.Text `json:"name"`
Phone pgtype.Text `json:"phone"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRow(ctx, createUser,
arg.Email,
arg.PasswordHash,
arg.Name,
arg.Phone,
)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const getTagByCode = `-- name: GetTagByCode :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE tag_code = $1
`
func (q *Queries) GetTagByCode(ctx context.Context, tagCode string) (Tag, error) {
row := q.db.QueryRow(ctx, getTagByCode, tagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getTagByID = `-- name: GetTagByID :one
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE id = $1
`
func (q *Queries) GetTagByID(ctx context.Context, id int64) (Tag, error) {
row := q.db.QueryRow(ctx, getTagByID, id)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getUserByEmail = `-- name: GetUserByEmail :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE email = $1
`
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) {
row := q.db.QueryRow(ctx, getUserByEmail, email)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, email, password_hash, name, phone, created_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Email,
&i.PasswordHash,
&i.Name,
&i.Phone,
&i.CreatedAt,
)
return i, err
}
const insertTag = `-- name: InsertTag :one
INSERT INTO tags (tag_code) VALUES ($1)
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
func (q *Queries) InsertTag(ctx context.Context, tagCode string) (Tag, error) {
row := q.db.QueryRow(ctx, insertTag, tagCode)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listTagsByOwner = `-- name: ListTagsByOwner :many
SELECT id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at FROM tags WHERE owner_id = $1 ORDER BY created_at DESC
`
func (q *Queries) ListTagsByOwner(ctx context.Context, ownerID pgtype.Int8) ([]Tag, error) {
rows, err := q.db.Query(ctx, listTagsByOwner, ownerID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Tag
for rows.Next() {
var i Tag
if err := rows.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setTagStatus = `-- name: SetTagStatus :exec
UPDATE tags SET status=$2, updated_at=now() WHERE id=$1
`
type SetTagStatusParams struct {
ID int64 `json:"id"`
Status string `json:"status"`
}
func (q *Queries) SetTagStatus(ctx context.Context, arg SetTagStatusParams) error {
_, err := q.db.Exec(ctx, setTagStatus, arg.ID, arg.Status)
return err
}
const updateTagDetails = `-- name: UpdateTagDetails :one
UPDATE tags
SET item_type=$2, description=$3, photo_url=$4, phone=$5, address=$6, notes=$7, status='active', updated_at=now()
WHERE id=$1
RETURNING id, tag_code, owner_id, status, item_type, description, photo_url, phone, address, notes, created_at, updated_at
`
type UpdateTagDetailsParams struct {
ID int64 `json:"id"`
ItemType pgtype.Text `json:"item_type"`
Description pgtype.Text `json:"description"`
PhotoUrl pgtype.Text `json:"photo_url"`
Phone pgtype.Text `json:"phone"`
Address pgtype.Text `json:"address"`
Notes pgtype.Text `json:"notes"`
}
func (q *Queries) UpdateTagDetails(ctx context.Context, arg UpdateTagDetailsParams) (Tag, error) {
row := q.db.QueryRow(ctx, updateTagDetails,
arg.ID,
arg.ItemType,
arg.Description,
arg.PhotoUrl,
arg.Phone,
arg.Address,
arg.Notes,
)
var i Tag
err := row.Scan(
&i.ID,
&i.TagCode,
&i.OwnerID,
&i.Status,
&i.ItemType,
&i.Description,
&i.PhotoUrl,
&i.Phone,
&i.Address,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}

View File

@@ -0,0 +1,98 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
func (a *App) RegisterPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "register", "Create account", nil, "")
}
func (a *App) Register(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
name := strings.TrimSpace(r.FormValue("name"))
if email == "" || password == "" {
a.render(w, r, "register", "Create account", nil, "Email and password are required.")
return
}
if len(password) < 8 {
a.render(w, r, "register", "Create account", nil, "Password must be at least 8 characters.")
return
}
if len([]byte(password)) > 72 {
a.render(w, r, "register", "Create account", nil, "Password must be 72 bytes or fewer.")
return
}
hash, err := auth.HashPassword(password)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
user, err := a.Queries.CreateUser(r.Context(), db.CreateUserParams{
Email: email,
PasswordHash: hash,
Name: pgtype.Text{String: name, Valid: name != ""},
})
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
a.render(w, r, "register", "Create account", nil, "That email is already registered.")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "login", "Log in", nil, "")
}
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
password := r.FormValue("password")
user, err := a.Queries.GetUserByEmail(r.Context(), email)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if !auth.CheckPassword(user.PasswordHash, password) {
a.render(w, r, "login", "Log in", nil, "Invalid email or password.")
return
}
if err := auth.SetUserID(w, r, user.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
_ = auth.Clear(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -0,0 +1,103 @@
// Package handlers wires templates, auth, and database queries for the
// WhereWoof GOAT front-end.
package handlers
import (
"html/template"
"net/http"
"strings"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
// Templates maps a page key to its parsed template set (base + page + partials).
// Each page is parsed as its own set so the shared "content" block name
// doesn't collide across pages.
type Templates map[string]*template.Template
// App holds dependencies shared by all handlers.
type App struct {
Queries *db.Queries
Tpl Templates
}
// New returns an App with the given query layer and template sets.
func New(queries *db.Queries, tpl Templates) *App {
return &App{Queries: queries, Tpl: tpl}
}
// PageData is the root data passed to the base layout.
type PageData struct {
CurrentUser *db.User
Title string
Error string
Data any
}
func titleCase(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// LoadTemplates parses every page's template set from templates/.
func LoadTemplates() (Templates, error) {
const dir = "templates"
base := dir + "/base.html"
pages := map[string][]string{
"index": {dir + "/index.html"},
"register": {dir + "/register.html"},
"login": {dir + "/login.html"},
"account": {dir + "/account.html", dir + "/account-panel.html", dir + "/tag-list.html"},
"edit": {dir + "/tag-edit.html"},
"public": {dir + "/tag-public.html"},
"notfound": {dir + "/not-found.html"},
}
funcs := template.FuncMap{"title": titleCase}
tpl := make(Templates, len(pages))
for name, files := range pages {
paths := append([]string{base}, files...)
t, err := template.New(name).Funcs(funcs).ParseFiles(paths...)
if err != nil {
return nil, err
}
tpl[name] = t
}
return tpl, nil
}
// render executes the page's base layout with a PageData populated from the
// authenticated user (if any).
func (a *App) render(w http.ResponseWriter, r *http.Request, page, title string, data any, errMsg string) {
pd := PageData{Title: title, Data: data, Error: errMsg}
if uid, ok := auth.GetUserID(r); ok {
if u, err := a.Queries.GetUserByID(r.Context(), uid); err == nil {
pd.CurrentUser = &u
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, "base", pd); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// renderPartial executes a named partial (e.g. "account-panel") for HTMX swaps.
func (a *App) renderPartial(w http.ResponseWriter, page, partial string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := a.Tpl[page].ExecuteTemplate(w, partial, data); err != nil {
http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
}
}
// RequireAuth redirects unauthenticated requests to /login.
func (a *App) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if _, ok := auth.GetUserID(r); !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}

View File

@@ -0,0 +1,59 @@
package handlers
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
)
type publicData struct {
ID int64
TagCode string
Status string
ItemType pgtype.Text
Description pgtype.Text
PhotoUrl pgtype.Text
Phone pgtype.Text
Address pgtype.Text
Notes pgtype.Text
IsOwner bool
}
func (a *App) Home(w http.ResponseWriter, r *http.Request) {
a.render(w, r, "index", "Home", nil, "")
}
// PublicTag renders the unauthenticated tag page: setup prompt, return details,
// or unavailable (suspended / not found).
func (a *App) PublicTag(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("tag_code")
tag, err := a.Queries.GetTagByCode(r.Context(), code)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
a.render(w, r, "notfound", "Tag not found", nil, "")
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
pd := publicData{
ID: tag.ID,
TagCode: tag.TagCode,
Status: tag.Status,
ItemType: tag.ItemType,
Description: tag.Description,
PhotoUrl: tag.PhotoUrl,
Phone: tag.Phone,
Address: tag.Address,
Notes: tag.Notes,
}
if uid, ok := auth.GetUserID(r); ok && tag.OwnerID.Valid && tag.OwnerID.Int64 == uid {
pd.IsOwner = true
}
a.render(w, r, "public", "Found item", pd, "")
}

View File

@@ -0,0 +1,152 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"wherewoof/frontend/internal/auth"
"wherewoof/frontend/internal/db"
)
const maxTagsPerAccount = 20
type accountData struct {
Tags []db.Tag
AddError string
}
func ownerID(uid int64) pgtype.Int8 {
return pgtype.Int8{Int64: uid, Valid: true}
}
func (a *App) Account(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.render(w, r, "account", "My Tags", accountData{Tags: tags}, "")
}
// AddTag binds a tag code to the current account (HTMX: returns account-panel).
func (a *App) AddTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
code := strings.ToUpper(strings.TrimSpace(r.FormValue("tag_code")))
oid := ownerID(uid)
data := accountData{}
if code == "" {
data.AddError = "Enter a tag code."
} else {
cnt, err := a.Queries.CountTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if cnt >= maxTagsPerAccount {
data.AddError = "Limit reached: each account can hold 20 tags."
} else {
_, err := a.Queries.BindTag(r.Context(), db.BindTagParams{OwnerID: oid, TagCode: code})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
data.AddError = "Tag not found, or already claimed by another account."
} else {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
}
}
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), oid)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
data.Tags = tags
a.renderPartial(w, "account", "account-panel", data)
}
func (a *App) EditTagPage(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
a.render(w, r, "edit", "Edit tag", tag, "")
}
func (a *App) EditTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if tag.Status == "suspended" {
a.render(w, r, "edit", "Edit tag", tag, "This tag is suspended and cannot be edited.")
return
}
params := db.UpdateTagDetailsParams{
ID: tag.ID,
ItemType: textOrNil(strings.TrimSpace(r.FormValue("item_type"))),
Description: textOrNil(strings.TrimSpace(r.FormValue("description"))),
PhotoUrl: textOrNil(strings.TrimSpace(r.FormValue("photo_url"))),
Phone: textOrNil(strings.TrimSpace(r.FormValue("phone"))),
Address: textOrNil(strings.TrimSpace(r.FormValue("address"))),
Notes: textOrNil(strings.TrimSpace(r.FormValue("notes"))),
}
if _, err := a.Queries.UpdateTagDetails(r.Context(), params); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account", http.StatusSeeOther)
}
// DeleteTag reverts a tag to unset so it can be re-bound (HTMX: account-panel).
func (a *App) DeleteTag(w http.ResponseWriter, r *http.Request) {
uid, _ := auth.GetUserID(r)
tag, ok := a.loadOwnedTag(w, r, uid)
if !ok {
return
}
if _, err := a.Queries.ClearTagOwner(r.Context(), tag.ID); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
tags, err := a.Queries.ListTagsByOwner(r.Context(), ownerID(uid))
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
a.renderPartial(w, "account", "account-panel", accountData{Tags: tags})
}
// loadOwnedTag fetches a tag by path id and verifies it belongs to uid.
func (a *App) loadOwnedTag(w http.ResponseWriter, r *http.Request, uid int64) (db.Tag, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return db.Tag{}, false
}
tag, err := a.Queries.GetTagByID(r.Context(), id)
if err != nil || !tag.OwnerID.Valid || tag.OwnerID.Int64 != uid {
http.NotFound(w, r)
return db.Tag{}, false
}
return tag, true
}
func textOrNil(s string) pgtype.Text {
return pgtype.Text{String: s, Valid: s != ""}
}