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"}} +
{{.AddError}}
{{end}} + ++ 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. +
+ +Scan the tag you found β the page shows the owner's return details.
+ My account +Welcome back to WhereWoof.
+ + {{if .Error}}{{.Error}}
{{end}} + + + +New to WhereWoof? Create an account
+We couldn't find a WhereWoof tag with that code. Check the code on the tag and try again.
+ Go home +Register to claim your WhereWoof tag.
+ + {{if .Error}}{{.Error}}
{{end}} + + + +Already have an account? Log in
+| Tag | +Status | +Item | ++ |
|---|---|---|---|
| {{.TagCode}} | ++ {{if eq .Status "active"}}Active + {{else if eq .Status "suspended"}}Suspended + {{else}}Unset{{end}} + | +{{if .ItemType.Valid}}{{.ItemType.String}}{{else}}β{{end}} | ++ Edit + + | +
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"}} +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.
+The owner has disabled this tag. Please try another way to return the item.
+This item has a WhereWoof tag. Here's how to return it:
+{{.Data.Description.String}}
+ {{end}} ++ Found this item? Please contact the owner to arrange the return. Thank you for helping! +
+ {{if .Data.IsOwner}} + + {{end}} +