From 133e375b6b8f1e640628a08b290c3f04e48bd207 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Wed, 5 Aug 2026 13:46:50 +1000 Subject: [PATCH] =?UTF-8?q?frontend-foundation:=20GOAT=20front-end=20Phase?= =?UTF-8?q?=201=20=E2=80=94=20auth,=20tag=20management,=20public=20tag=20p?= =?UTF-8?q?age=20(22/22=20spec=20scenarios=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Makefile | 22 ++ db/schema.sql | 39 +++ frontend/cmd/migrate/main.go | 46 ++++ frontend/cmd/seed/main.go | 49 ++++ frontend/go.mod | 18 ++ frontend/go.sum | 34 +++ frontend/internal/auth/passwords.go | 18 ++ frontend/internal/auth/session.go | 51 ++++ frontend/internal/db/db.go | 32 +++ frontend/internal/db/models.go | 44 ++++ frontend/internal/db/querier.go | 28 +++ frontend/internal/db/queries.sql | 47 ++++ frontend/internal/db/queries.sql.go | 319 +++++++++++++++++++++++++ frontend/internal/handlers/auth.go | 98 ++++++++ frontend/internal/handlers/handlers.go | 103 ++++++++ frontend/internal/handlers/tag_page.go | 59 +++++ frontend/internal/handlers/tags.go | 152 ++++++++++++ frontend/main.go | 71 ++++++ frontend/sqlc.yaml | 12 + frontend/templates/account-panel.html | 16 ++ frontend/templates/account.html | 7 + frontend/templates/base.html | 35 +++ frontend/templates/index.html | 24 ++ frontend/templates/login.html | 22 ++ frontend/templates/not-found.html | 8 + frontend/templates/register.html | 26 ++ frontend/templates/tag-edit.html | 49 ++++ frontend/templates/tag-list.html | 37 +++ frontend/templates/tag-public.html | 64 +++++ plans/frontend-foundation.md | 58 +++++ 31 files changed, 1591 insertions(+) create mode 100644 Makefile create mode 100644 db/schema.sql create mode 100644 frontend/cmd/migrate/main.go create mode 100644 frontend/cmd/seed/main.go create mode 100644 frontend/go.mod create mode 100644 frontend/go.sum create mode 100644 frontend/internal/auth/passwords.go create mode 100644 frontend/internal/auth/session.go create mode 100644 frontend/internal/db/db.go create mode 100644 frontend/internal/db/models.go create mode 100644 frontend/internal/db/querier.go create mode 100644 frontend/internal/db/queries.sql create mode 100644 frontend/internal/db/queries.sql.go create mode 100644 frontend/internal/handlers/auth.go create mode 100644 frontend/internal/handlers/handlers.go create mode 100644 frontend/internal/handlers/tag_page.go create mode 100644 frontend/internal/handlers/tags.go create mode 100644 frontend/main.go create mode 100644 frontend/sqlc.yaml create mode 100644 frontend/templates/account-panel.html create mode 100644 frontend/templates/account.html create mode 100644 frontend/templates/base.html create mode 100644 frontend/templates/index.html create mode 100644 frontend/templates/login.html create mode 100644 frontend/templates/not-found.html create mode 100644 frontend/templates/register.html create mode 100644 frontend/templates/tag-edit.html create mode 100644 frontend/templates/tag-list.html create mode 100644 frontend/templates/tag-public.html create mode 100644 plans/frontend-foundation.md diff --git a/.gitignore b/.gitignore index 398877f..5da2f3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Pi agent local state (machine-specific config, memory, tasks) .pi/ +# Build artifacts +frontend/where-woof + # Editor/OS noise *.swp .DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41ca3b9 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +DATABASE_URL ?= postgres://wherewoof:ww_dev_2026@192.168.20.13:5434/wherewoof +export DATABASE_URL + +.PHONY: db-up seed run build generate psql + +db-up: ## apply db/schema.sql (embedded migrator, no psql needed) + cd frontend && go run ./cmd/migrate -schema ../db/schema.sql + +seed: ## insert test tag codes TEST000001..TEST000010 + cd frontend && go run ./cmd/seed + +run: ## run the web server (port 3020) + cd frontend && go run . + +build: ## build linux amd64 binary for .13 + cd frontend && GOOS=linux GOARCH=amd64 go build -o where-woof . + +generate: ## regenerate sqlc query code + cd frontend && sqlc generate + +psql: ## ad-hoc SQL shell into wherewoof-db on .13 (no local psql install) + ssh sam@192.168.20.13 docker exec -i wherewoof-db psql -U wherewoof -d wherewoof diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..a39f2b3 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,39 @@ +-- WhereWoof canonical schema (Phase 1) +-- Source of truth until Laravel takes over migration ownership (Phase 4). +-- Applied by: make db-up (frontend/cmd/migrate) + +CREATE TABLE IF NOT EXISTS users ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT, + phone TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS tags ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier + owner_id BIGINT REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'unset' + CHECK (status IN ('unset', 'active', 'suspended')), + item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')), + description TEXT, + photo_url TEXT, + phone TEXT, -- owner contact phone (shown via tel:) + address TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS scans ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tag_id BIGINT NOT NULL REFERENCES tags(id), + scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + lat DOUBLE PRECISION, + lng DOUBLE PRECISION, + location_shared BOOLEAN NOT NULL DEFAULT FALSE, + scanner_phone TEXT, + alert_sent BOOLEAN NOT NULL DEFAULT FALSE +); diff --git a/frontend/cmd/migrate/main.go b/frontend/cmd/migrate/main.go new file mode 100644 index 0000000..7875d0b --- /dev/null +++ b/frontend/cmd/migrate/main.go @@ -0,0 +1,46 @@ +// Command migrate applies db/schema.sql to DATABASE_URL. +// Usage: go run ./cmd/migrate [-schema db/schema.sql] +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func main() { + schemaPath := flag.String("schema", "db/schema.sql", "path to schema file") + flag.Parse() + + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set") + os.Exit(1) + } + + sqlBytes, err := os.ReadFile(*schemaPath) + if err != nil { + fmt.Fprintln(os.Stderr, "error reading schema:", err) + os.Exit(1) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "error connecting:", err) + os.Exit(1) + } + defer pool.Close() + + if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil { + fmt.Fprintln(os.Stderr, "error applying schema:", err) + os.Exit(1) + } + fmt.Println("schema applied to", dsn) +} diff --git a/frontend/cmd/seed/main.go b/frontend/cmd/seed/main.go new file mode 100644 index 0000000..d9c2b87 --- /dev/null +++ b/frontend/cmd/seed/main.go @@ -0,0 +1,49 @@ +// Command seed inserts test tag codes TEST000001..TEST000025. +// Idempotent: already-existing codes are skipped. +package main + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "wherewoof/frontend/internal/db" +) + +const count = 25 + +func main() { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + fmt.Fprintln(os.Stderr, "error: DATABASE_URL not set") + os.Exit(1) + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + fmt.Fprintln(os.Stderr, "connect:", err) + os.Exit(1) + } + defer pool.Close() + + q := db.New(pool) + for i := 1; i <= count; i++ { + code := fmt.Sprintf("TEST%06d", i) + if _, err := q.InsertTag(ctx, code); err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + fmt.Println("skip (exists)", code) + continue + } + fmt.Fprintln(os.Stderr, "insert", code, ":", err) + os.Exit(1) + } + fmt.Println("seeded", code) + } + fmt.Printf("done: %d test tags available\n", count) +} diff --git a/frontend/go.mod b/frontend/go.mod new file mode 100644 index 0000000..c4ac69d --- /dev/null +++ b/frontend/go.mod @@ -0,0 +1,18 @@ +module wherewoof/frontend + +go 1.26 + +require ( + github.com/gorilla/sessions v1.4.0 + github.com/jackc/pgx/v5 v5.10.0 + golang.org/x/crypto v0.54.0 +) + +require ( + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/frontend/go.sum b/frontend/go.sum new file mode 100644 index 0000000..a16b5ec --- /dev/null +++ b/frontend/go.sum @@ -0,0 +1,34 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/frontend/internal/auth/passwords.go b/frontend/internal/auth/passwords.go new file mode 100644 index 0000000..7951b17 --- /dev/null +++ b/frontend/internal/auth/passwords.go @@ -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 +} diff --git a/frontend/internal/auth/session.go b/frontend/internal/auth/session.go new file mode 100644 index 0000000..4b3bf94 --- /dev/null +++ b/frontend/internal/auth/session.go @@ -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) +} diff --git a/frontend/internal/db/db.go b/frontend/internal/db/db.go new file mode 100644 index 0000000..468d1fa --- /dev/null +++ b/frontend/internal/db/db.go @@ -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, + } +} diff --git a/frontend/internal/db/models.go b/frontend/internal/db/models.go new file mode 100644 index 0000000..a084100 --- /dev/null +++ b/frontend/internal/db/models.go @@ -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"` +} diff --git a/frontend/internal/db/querier.go b/frontend/internal/db/querier.go new file mode 100644 index 0000000..d7c7617 --- /dev/null +++ b/frontend/internal/db/querier.go @@ -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) diff --git a/frontend/internal/db/queries.sql b/frontend/internal/db/queries.sql new file mode 100644 index 0000000..08e4f50 --- /dev/null +++ b/frontend/internal/db/queries.sql @@ -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 *; diff --git a/frontend/internal/db/queries.sql.go b/frontend/internal/db/queries.sql.go new file mode 100644 index 0000000..3e980bb --- /dev/null +++ b/frontend/internal/db/queries.sql.go @@ -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 +} diff --git a/frontend/internal/handlers/auth.go b/frontend/internal/handlers/auth.go new file mode 100644 index 0000000..130e2ae --- /dev/null +++ b/frontend/internal/handlers/auth.go @@ -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) +} diff --git a/frontend/internal/handlers/handlers.go b/frontend/internal/handlers/handlers.go new file mode 100644 index 0000000..048ccc4 --- /dev/null +++ b/frontend/internal/handlers/handlers.go @@ -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) + } +} diff --git a/frontend/internal/handlers/tag_page.go b/frontend/internal/handlers/tag_page.go new file mode 100644 index 0000000..434a2e6 --- /dev/null +++ b/frontend/internal/handlers/tag_page.go @@ -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, "") +} diff --git a/frontend/internal/handlers/tags.go b/frontend/internal/handlers/tags.go new file mode 100644 index 0000000..4fe8d0b --- /dev/null +++ b/frontend/internal/handlers/tags.go @@ -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 != ""} +} diff --git a/frontend/main.go b/frontend/main.go new file mode 100644 index 0000000..2f83fea --- /dev/null +++ b/frontend/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "wherewoof/frontend/internal/auth" + "wherewoof/frontend/internal/db" + "wherewoof/frontend/internal/handlers" +) + +func main() { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + log.Fatal("DATABASE_URL not set") + } + secret := os.Getenv("SESSION_SECRET") + if secret == "" { + log.Fatal("SESSION_SECRET not set") + } + addr := os.Getenv("ADDR") + if addr == "" { + addr = ":3020" + } + + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + log.Fatal("connect:", err) + } + defer pool.Close() + + auth.InitSessionStore(secret) + + tpl, err := handlers.LoadTemplates() + if err != nil { + log.Fatal("templates:", err) + } + + app := handlers.New(db.New(pool), tpl) + + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + mux.HandleFunc("GET /{$}", app.Home) + mux.HandleFunc("/{path...}", http.NotFound) + mux.HandleFunc("GET /t/{tag_code}", app.PublicTag) + mux.HandleFunc("GET /register", app.RegisterPage) + mux.HandleFunc("POST /register", app.Register) + mux.HandleFunc("GET /login", app.LoginPage) + mux.HandleFunc("POST /login", app.Login) + mux.HandleFunc("POST /logout", app.Logout) + mux.Handle("GET /account", app.RequireAuth(app.Account)) + mux.Handle("POST /account/tags", app.RequireAuth(app.AddTag)) + mux.Handle("GET /account/tags/{id}/edit", app.RequireAuth(app.EditTagPage)) + mux.Handle("POST /account/tags/{id}/edit", app.RequireAuth(app.EditTag)) + mux.Handle("POST /account/tags/{id}/delete", app.RequireAuth(app.DeleteTag)) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + log.Println("wherewoof listening on", addr) + log.Fatal(srv.ListenAndServe()) +} diff --git a/frontend/sqlc.yaml b/frontend/sqlc.yaml new file mode 100644 index 0000000..9483caa --- /dev/null +++ b/frontend/sqlc.yaml @@ -0,0 +1,12 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/db/queries.sql" + schema: "../db/schema.sql" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "pgx/v5" + emit_interface: true + emit_json_tags: true diff --git a/frontend/templates/account-panel.html b/frontend/templates/account-panel.html new file mode 100644 index 0000000..b835065 --- /dev/null +++ b/frontend/templates/account-panel.html @@ -0,0 +1,16 @@ +{{define "account-panel"}} +
+ {{if .AddError}}

{{.AddError}}

{{end}} + +
+
+ + +
+
+ +
+ {{template "tag-list" .Tags}} +
+
+{{end}} diff --git a/frontend/templates/account.html b/frontend/templates/account.html new file mode 100644 index 0000000..9a88152 --- /dev/null +++ b/frontend/templates/account.html @@ -0,0 +1,7 @@ +{{define "content"}} +
+

My Tags

+ ← Home +
+{{template "account-panel" .Data}} +{{end}} diff --git a/frontend/templates/base.html b/frontend/templates/base.html new file mode 100644 index 0000000..0c78fbd --- /dev/null +++ b/frontend/templates/base.html @@ -0,0 +1,35 @@ +{{define "base"}} + + + + + + {{.Title}} Β· WhereWoof + + + + + + +
+ {{template "content" .}} +
+ + +{{end}} diff --git a/frontend/templates/index.html b/frontend/templates/index.html new file mode 100644 index 0000000..cd8db9b --- /dev/null +++ b/frontend/templates/index.html @@ -0,0 +1,24 @@ +{{define "content"}} +
+

Lost something?
The tag brings it home.

+

+ WhereWoof tags carry the return details for your dog, baggage, skis β€” anything you care about. + A finder scans the QR code or taps the NFC tag and sees exactly how to get it back to you. +

+ +
+
+
🏷️
+

I have a tag

+

Claim it and set up the return details for your item.

+ Set up my tag +
+
+
πŸ”
+

I found something

+

Scan the tag you found β€” the page shows the owner's return details.

+ My account +
+
+
+{{end}} diff --git a/frontend/templates/login.html b/frontend/templates/login.html new file mode 100644 index 0000000..588bfc0 --- /dev/null +++ b/frontend/templates/login.html @@ -0,0 +1,22 @@ +{{define "content"}} +
+

Log in

+

Welcome back to WhereWoof.

+ + {{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +
+
+ + +
+ +
+ +

New to WhereWoof? Create an account

+
+{{end}} diff --git a/frontend/templates/not-found.html b/frontend/templates/not-found.html new file mode 100644 index 0000000..ed8fd4d --- /dev/null +++ b/frontend/templates/not-found.html @@ -0,0 +1,8 @@ +{{define "content"}} +
+
πŸ”Ž
+

Tag not found

+

We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.

+ Go home +
+{{end}} diff --git a/frontend/templates/register.html b/frontend/templates/register.html new file mode 100644 index 0000000..3e6d47d --- /dev/null +++ b/frontend/templates/register.html @@ -0,0 +1,26 @@ +{{define "content"}} +
+

Create your account

+

Register to claim your WhereWoof tag.

+ + {{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

Already have an account? Log in

+
+{{end}} diff --git a/frontend/templates/tag-edit.html b/frontend/templates/tag-edit.html new file mode 100644 index 0000000..2575122 --- /dev/null +++ b/frontend/templates/tag-edit.html @@ -0,0 +1,49 @@ +{{define "content"}} +
+
+

Edit tag

+ ← My Tags +
+

Tag {{.Data.TagCode}}

+ + {{if .Error}}

{{.Error}}

{{end}} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + Cancel +
+
+
+{{end}} diff --git a/frontend/templates/tag-list.html b/frontend/templates/tag-list.html new file mode 100644 index 0000000..4855610 --- /dev/null +++ b/frontend/templates/tag-list.html @@ -0,0 +1,37 @@ +{{define "tag-list"}} +{{if .}} +
+ + + + + + + + + + + {{range .}} + + + + + + + {{end}} + +
TagStatusItem
{{.TagCode}} + {{if eq .Status "active"}}Active + {{else if eq .Status "suspended"}}Suspended + {{else}}Unset{{end}} + {{if .ItemType.Valid}}{{.ItemType.String}}{{else}}β€”{{end}} + Edit +
+ +
+
+
+{{else}} +

No tags yet. Enter your tag code above to claim it.

+{{end}} +{{end}} diff --git a/frontend/templates/tag-public.html b/frontend/templates/tag-public.html new file mode 100644 index 0000000..f820b89 --- /dev/null +++ b/frontend/templates/tag-public.html @@ -0,0 +1,64 @@ +{{define "content"}} +
+ {{if eq .Data.Status "unset"}} +
+
🏷️
+

This tag isn't set up yet

+

This WhereWoof tag hasn't been claimed. If this is your tag, log in and add it to your account to set up the return details.

+ +
+ {{else if eq .Data.Status "suspended"}} +
+
🚫
+

This tag is unavailable

+

The owner has disabled this tag. Please try another way to return the item.

+
+ {{else}} +
+
+

+ {{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} β€” found! +

+

This item has a WhereWoof tag. Here's how to return it:

+
+
+ {{if .Data.PhotoUrl.Valid}} + Item photo + {{end}} + {{if .Data.Description.Valid}} +

{{.Data.Description.String}}

+ {{end}} +
+ {{if .Data.Phone.Valid}} +
+
Call the owner
+
{{.Data.Phone.String}}
+
+ {{end}} + {{if .Data.Address.Valid}} +
+
Return to
+
{{.Data.Address.String}}
+
+ {{end}} + {{if .Data.Notes.Valid}} +
+
Notes
+
{{.Data.Notes.String}}
+
+ {{end}} +
+

+ Found this item? Please contact the owner to arrange the return. Thank you for helping! +

+ {{if .Data.IsOwner}} +

✏️ Edit this tag's details

+ {{end}} +
+
+ {{end}} +
+{{end}} diff --git a/plans/frontend-foundation.md b/plans/frontend-foundation.md new file mode 100644 index 0000000..40d76ef --- /dev/null +++ b/plans/frontend-foundation.md @@ -0,0 +1,58 @@ +# Phase 1 β€” Frontend Foundation (GOAT + Postgres) + +Plan for OpenSpec change `frontend-foundation` (proposal/design/specs/tasks in `openspec/changes/frontend-foundation/`). + +## Context + +WhereWoof is a return-tag platform (not a tracker). This change builds the GOAT front-end foundation: a Go app serving owner auth, tag setup/management, and the public tag page, against a single shared Postgres. Laravel admin, scan flow (geolocation/SMS), billing, and deployment are later phases. + +Current state: greenfield. `frontend/` and `admin/` are empty. Go 1.26.2 installed on .27. `sqlc` not installed (dev-only codegen β€” install on .27, generated code committed). `psql` not installed anywhere (ad-hoc SQL via docker exec on .13). .13 reachable over SSH with Docker (sam user). No long-term installs on .27 β€” everything runtime (DB + binary) lives on .13. + +Execution style (agreed): master/worker β€” main pi session orchestrates; subagents do chunks (`database` for schema/queries, `coder-pro` for Go code, `code-analysis` for review). + +## Approach + +- **`frontend/`** Go module (module path `wherewoof/frontend`, stdlib `net/http` + Go 1.22+ ServeMux, no framework). +- Layout: `main.go`; `internal/db` (pgx pool + queries); `internal/auth` (sessions + bcrypt); `internal/handlers`; `templates/` (html/template); `static/`. +- **`db/schema.sql`** at repo root = canonical schema (users, tags, scans). Applied via a small embedded Go migrator (`make db-up`) β€” no psql dependency. Ad-hoc SQL via `make psql` β†’ `ssh sam@192.168.20.13 docker exec -i wherewoof-db psql ...`. +- **Auth**: gorilla/sessions cookie store (HttpOnly, SameSite=Lax, `SESSION_SECRET` env), bcrypt passwords, `RequireAuth` middleware, `currentUser` for templates. +- **Routes** (Phase 1 subset of `where_woof.md`): `/`, `/t/{tag_code}`, `/register`, `/login`, `/logout`, `/account`, `/account/tags` (add), `/account/tags/{id}/edit`, `/account/tags/{id}/delete`. +- **Templates**: `base.html` + per-page; HTMX partials for account list/edit; Alpine for small behaviours; Tailwind via CDN. +- **Seed**: `cmd/seed` inserts `TEST000001..TEST000010` tag codes. +- **Postgres (dev + prod, on .13)**: dedicated `wherewoof-db` Postgres 16 container on .13 (`~/Docker/Containers/wherewoof-db/docker-compose.yml`, port 5433β†’**5434** (5433 = Langfuse PG, 5432 = ai-resume PG), named volume for data, app user `wherewoof`). Dev on .27 connects over LAN: `postgres://wherewoof:@192.168.20.13:5434/wherewoof`. Same DB host from day one β€” no .27 installs. + +## Files to create / modify + +- `frontend/go.mod`, `frontend/main.go` +- `frontend/internal/db/db.go`, `queries.sql` (+ generated code) +- `frontend/internal/auth/session.go`, `passwords.go` +- `frontend/internal/handlers/auth.go`, `tags.go`, `tag_page.go` +- `frontend/templates/base.html`, `index.html`, `register.html`, `login.html`, `account.html`, `tag-edit.html`, `tag-public.html`, `not-found.html` +- `frontend/static/style.css` +- `frontend/cmd/seed/main.go` +- `db/schema.sql` +- `Makefile`, `sqlc.yaml` +- (infra, on .13, not in repo): `~/Docker/Containers/wherewoof-db/docker-compose.yml` + +## Reuse + +- `openspec/changes/frontend-foundation/specs/*/spec.md` β€” each requirement is a test checklist. +- `where_woof.md` β€” endpoints table, schema v2, scan-flow context (Phase 2 reference). +- `awesome-design.md` / `mockups/` β€” styling tokens for Tailwind (optional, Phase 1 minimal). + +## Steps + +- [x] 1. Provision Postgres on .13: `wherewoof-db` container (port 5433, volume), create `wherewoof` DB + app user (via SSH + docker exec) +- [x] 2. Install sqlc on .27 (`go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`); scaffold Go module + dirs; `db/schema.sql`; Makefile (`db-up`, `seed`, `run`, `generate`, `psql`) +- [x] 3. sqlc.yaml + `queries.sql` for users/tags CRUD; generate +- [x] 4. Config + `main.go` wiring (pool, router, templates, static) +- [x] 5. Auth: register (bcrypt, unique email, auto-login), login/logout, RequireAuth, currentUser +- [x] 6. Tag management: bind by code (reject owned, 20-limit), My Tags list, edit β†’ active, remove β†’ unset +- [x] 7. Public tag page: lookup, not-found page, unset prompt / active details / suspended, owner edit affordance +- [x] 8. Base layout + Tailwind; home page +- [x] 9. Seed; end-to-end verification of every spec scenario; `openspec validate`; commit + +## Verification + +- `make db-up && make seed && make run` (DATABASE_URL β†’ `192.168.20.13:5433`) β†’ register β†’ add tag (TEST000001) β†’ edit details β†’ public page shows details; duplicate email rejected; second account can't bind owned tag; 21st tag rejected; remove β†’ re-bind works; unknown code β†’ not-found page; suspended tag β†’ unavailable. +- Run `openspec validate frontend-foundation`; commit code + artifacts.