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

3
.gitignore vendored
View File

@@ -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

22
Makefile Normal file
View File

@@ -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

39
db/schema.sql Normal file
View File

@@ -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
);

View File

@@ -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)
}

49
frontend/cmd/seed/main.go Normal file
View File

@@ -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)
}

18
frontend/go.mod Normal file
View File

@@ -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
)

34
frontend/go.sum Normal file
View File

@@ -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=

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 != ""}
}

71
frontend/main.go Normal file
View File

@@ -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())
}

12
frontend/sqlc.yaml Normal file
View File

@@ -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

View File

@@ -0,0 +1,16 @@
{{define "account-panel"}}
<div id="account-panel">
{{if .AddError}}<p class="mt-4 rounded border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{{.AddError}}</p>{{end}}
<div class="mt-4 rounded-xl border border-stone-200 bg-white p-4 shadow-sm">
<form hx-post="/account/tags" hx-target="#account-panel" hx-swap="outerHTML" class="flex gap-2">
<input name="tag_code" type="text" placeholder="Enter tag code (e.g. TEST000001)" class="flex-1 rounded border border-stone-300 px-3 py-2 text-sm" required>
<button type="submit" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Add tag</button>
</form>
</div>
<div id="tag-list">
{{template "tag-list" .Tags}}
</div>
</div>
{{end}}

View File

@@ -0,0 +1,7 @@
{{define "content"}}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">My Tags</h1>
<a href="/" class="text-sm text-stone-600 hover:underline">← Home</a>
</div>
{{template "account-panel" .Data}}
{{end}}

View File

@@ -0,0 +1,35 @@
{{define "base"}}
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · WhereWoof</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="min-h-full bg-stone-100 text-stone-900 antialiased">
<nav class="bg-stone-900 text-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<a href="/" class="text-lg font-bold tracking-tight">🐕 WhereWoof</a>
<div class="flex items-center gap-4 text-sm">
{{if .CurrentUser}}
<span class="text-stone-300">Hi, {{.CurrentUser.Name}}</span>
<a href="/account" class="hover:underline">My Tags</a>
<form method="post" action="/logout" class="inline">
<button class="hover:underline">Log out</button>
</form>
{{else}}
<a href="/login" class="hover:underline">Log in</a>
<a href="/register" class="rounded bg-amber-500 px-3 py-1 font-semibold text-stone-900 hover:bg-amber-400">Register</a>
{{end}}
</div>
</div>
</nav>
<main class="mx-auto max-w-4xl px-4 py-8">
{{template "content" .}}
</main>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,24 @@
{{define "content"}}
<div class="mx-auto max-w-2xl text-center">
<h1 class="text-4xl font-extrabold tracking-tight">Lost something?<br><span class="text-amber-600">The tag brings it home.</span></h1>
<p class="mt-4 text-lg text-stone-600">
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.
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
<div class="text-2xl">🏷️</div>
<h2 class="mt-2 font-bold">I have a tag</h2>
<p class="mt-1 text-sm text-stone-600">Claim it and set up the return details for your item.</p>
<a href="/register" class="mt-4 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Set up my tag</a>
</div>
<div class="rounded-xl border border-stone-200 bg-white p-6 text-left shadow-sm">
<div class="text-2xl">🔍</div>
<h2 class="mt-2 font-bold">I found something</h2>
<p class="mt-1 text-sm text-stone-600">Scan the tag you found — the page shows the owner's return details.</p>
<a href="/account" class="mt-4 inline-block rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">My account</a>
</div>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,22 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Log in</h1>
<p class="mt-1 text-sm text-stone-600">Welcome back to WhereWoof.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/login" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium" for="email">Email</label>
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="password">Password</label>
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Log in</button>
</form>
<p class="mt-4 text-center text-sm text-stone-600">New to WhereWoof? <a href="/register" class="text-amber-600 hover:underline">Create an account</a></p>
</div>
{{end}}

View File

@@ -0,0 +1,8 @@
{{define "content"}}
<div class="mx-auto max-w-md text-center">
<div class="text-5xl">🔎</div>
<h1 class="mt-4 text-2xl font-bold">Tag not found</h1>
<p class="mt-2 text-stone-600">We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.</p>
<a href="/" class="mt-6 inline-block rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Go home</a>
</div>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "content"}}
<div class="mx-auto max-w-md">
<h1 class="text-2xl font-bold">Create your account</h1>
<p class="mt-1 text-sm text-stone-600">Register to claim your WhereWoof tag.</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/register" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium" for="name">Name</label>
<input id="name" name="name" type="text" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="email">Email</label>
<input id="email" name="email" type="email" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" required>
</div>
<div>
<label class="block text-sm font-medium" for="password">Password</label>
<input id="password" name="password" type="password" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" minlength="8" required>
</div>
<button type="submit" class="w-full rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Create account</button>
</form>
<p class="mt-4 text-center text-sm text-stone-600">Already have an account? <a href="/login" class="text-amber-600 hover:underline">Log in</a></p>
</div>
{{end}}

View File

@@ -0,0 +1,49 @@
{{define "content"}}
<div class="mx-auto max-w-xl">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Edit tag</h1>
<a href="/account" class="text-sm text-stone-600 hover:underline">← My Tags</a>
</div>
<p class="mt-1 font-mono text-xs text-stone-500">Tag {{.Data.TagCode}}</p>
{{if .Error}}<p class="mt-4 rounded bg-red-50 px-4 py-2 text-sm text-red-700 border border-red-200">{{.Error}}</p>{{end}}
<form method="post" action="/account/tags/{{.Data.ID}}/edit" class="mt-6 space-y-4 rounded-xl border border-stone-200 bg-white p-6 shadow-sm">
<div>
<label class="block text-sm font-medium">Item type</label>
<select name="item_type" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
<option value="">Select…</option>
<option value="dog" {{if eq .Data.ItemType.String "dog"}}selected{{end}}>Dog</option>
<option value="cat" {{if eq .Data.ItemType.String "cat"}}selected{{end}}>Cat</option>
<option value="baggage" {{if eq .Data.ItemType.String "baggage"}}selected{{end}}>Baggage</option>
<option value="skis" {{if eq .Data.ItemType.String "skis"}}selected{{end}}>Skis</option>
<option value="other" {{if eq .Data.ItemType.String "other"}}selected{{end}}>Other</option>
</select>
</div>
<div>
<label class="block text-sm font-medium">Description</label>
<textarea name="description" rows="3" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Description.Valid}}{{.Data.Description.String}}{{end}}</textarea>
</div>
<div>
<label class="block text-sm font-medium">Photo URL</label>
<input name="photo_url" type="url" value="{{if .Data.PhotoUrl.Valid}}{{.Data.PhotoUrl.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="https://…">
</div>
<div>
<label class="block text-sm font-medium">Phone number (shown to finders)</label>
<input name="phone" type="tel" value="{{if .Data.Phone.Valid}}{{.Data.Phone.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2" placeholder="+61…">
</div>
<div>
<label class="block text-sm font-medium">Address</label>
<input name="address" type="text" value="{{if .Data.Address.Valid}}{{.Data.Address.String}}{{end}}" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium">Notes</label>
<textarea name="notes" rows="2" class="mt-1 w-full rounded border border-stone-300 px-3 py-2">{{if .Data.Notes.Valid}}{{.Data.Notes.String}}{{end}}</textarea>
</div>
<div class="flex gap-3">
<button type="submit" class="rounded bg-stone-900 px-4 py-2 font-semibold text-white hover:bg-stone-700">Save details</button>
<a href="/account" class="rounded border border-stone-300 px-4 py-2 text-sm font-medium hover:bg-stone-50">Cancel</a>
</div>
</form>
</div>
{{end}}

View File

@@ -0,0 +1,37 @@
{{define "tag-list"}}
{{if .}}
<div class="mt-4 overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<table class="w-full text-left text-sm">
<thead class="bg-stone-50 text-stone-600">
<tr>
<th class="px-4 py-2 font-medium">Tag</th>
<th class="px-4 py-2 font-medium">Status</th>
<th class="px-4 py-2 font-medium">Item</th>
<th class="px-4 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
{{range .}}
<tr class="border-t border-stone-100">
<td class="px-4 py-2 font-mono text-xs">{{.TagCode}}</td>
<td class="px-4 py-2">
{{if eq .Status "active"}}<span class="rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800">Active</span>
{{else if eq .Status "suspended"}}<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800">Suspended</span>
{{else}}<span class="rounded-full bg-stone-200 px-2 py-0.5 text-xs font-medium text-stone-700">Unset</span>{{end}}
</td>
<td class="px-4 py-2">{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}—{{end}}</td>
<td class="px-4 py-2 text-right">
<a href="/account/tags/{{.ID}}/edit" class="text-amber-600 hover:underline">Edit</a>
<form hx-post="/account/tags/{{.ID}}/delete" hx-target="#account-panel" hx-swap="outerHTML" hx-confirm="Remove this tag from your account?" class="inline">
<button class="ml-3 text-red-600 hover:underline">Remove</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="mt-6 text-center text-stone-500">No tags yet. Enter your tag code above to claim it.</p>
{{end}}
{{end}}

View File

@@ -0,0 +1,64 @@
{{define "content"}}
<div class="mx-auto max-w-2xl">
{{if eq .Data.Status "unset"}}
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🏷️</div>
<h1 class="mt-4 text-2xl font-bold">This tag isn't set up yet</h1>
<p class="mt-2 text-stone-600">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.</p>
<div class="mt-6 flex justify-center gap-3">
<a href="/login" class="rounded bg-stone-900 px-4 py-2 text-sm font-semibold text-white hover:bg-stone-700">Log in</a>
<a href="/register" class="rounded bg-amber-500 px-4 py-2 text-sm font-semibold text-stone-900 hover:bg-amber-400">Create account</a>
</div>
</div>
{{else if eq .Data.Status "suspended"}}
<div class="rounded-xl border border-stone-200 bg-white p-8 text-center shadow-sm">
<div class="text-5xl">🚫</div>
<h1 class="mt-4 text-2xl font-bold">This tag is unavailable</h1>
<p class="mt-2 text-stone-600">The owner has disabled this tag. Please try another way to return the item.</p>
</div>
{{else}}
<div class="overflow-hidden rounded-xl border border-stone-200 bg-white shadow-sm">
<div class="bg-stone-900 px-6 py-5 text-white">
<h1 class="text-2xl font-bold">
{{if .Data.ItemType.Valid}}{{.Data.ItemType.String | title}}{{else}}Item{{end}} — found!
</h1>
<p class="mt-1 text-sm text-stone-300">This item has a WhereWoof tag. Here's how to return it:</p>
</div>
<div class="p-6">
{{if .Data.PhotoUrl.Valid}}
<img src="{{.Data.PhotoUrl.String}}" alt="Item photo" class="mb-4 h-56 w-full rounded-lg object-cover">
{{end}}
{{if .Data.Description.Valid}}
<p class="text-stone-700">{{.Data.Description.String}}</p>
{{end}}
<dl class="mt-4 space-y-2 text-sm">
{{if .Data.Phone.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Call the owner</dt>
<dd><a href="tel:{{.Data.Phone.String}}" class="font-semibold text-amber-600 hover:underline">{{.Data.Phone.String}}</a></dd>
</div>
{{end}}
{{if .Data.Address.Valid}}
<div class="flex items-center justify-between rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Return to</dt>
<dd class="text-right">{{.Data.Address.String}}</dd>
</div>
{{end}}
{{if .Data.Notes.Valid}}
<div class="rounded bg-stone-50 px-4 py-3">
<dt class="font-medium">Notes</dt>
<dd class="mt-1 text-stone-600">{{.Data.Notes.String}}</dd>
</div>
{{end}}
</dl>
<p class="mt-6 rounded bg-amber-50 px-4 py-3 text-sm text-amber-800 border border-amber-200">
Found this item? Please contact the owner to arrange the return. Thank you for helping!
</p>
{{if .Data.IsOwner}}
<p class="mt-4 text-center text-sm"><a href="/account/tags/{{.Data.ID}}/edit" class="text-amber-600 hover:underline">✏️ Edit this tag's details</a></p>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -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:<pw>@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.