Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
NAPLATA_DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/postgres?sslmode=disable
NAPLATA_LISTEN_ADDR=:8080
NAPLATA_ENV=development
NAPLATA_TEST_DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/naplata_test?sslmode=disable
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ jobs:
go:
name: Go
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: naplata_test
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
NAPLATA_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/naplata_test?sslmode=disable
steps:
- uses: actions/checkout@v7

Expand Down
3 changes: 3 additions & 0 deletions cmd/naplata/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func main() {
// Current user
mux.Handle("GET /api/v1/me", h.RequireAuth(http.HandlerFunc(h.MeEndpoint)))

// Admin
mux.Handle("GET /api/v1/admin/clients", h.RequirePermission(http.HandlerFunc(h.ListClientsEndpoint), permissions.ClientsView))

slog.Info("server listening", "addr", cfg.ListenAddr)
log.Fatal(http.ListenAndServe(cfg.ListenAddr, sessionManager.LoadAndSave(mux)))
}
68 changes: 68 additions & 0 deletions internal/database/clients.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package database

import (
"context"
"fmt"
"strings"
"time"

"github.com/jackc/pgx/v5"
)

type Client struct {
ID string `db:"id" json:"id"`
Email string `db:"email" json:"email"`
GivenName string `db:"given_name" json:"given_name"`
FamilyName string `db:"family_name" json:"family_name"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}

type ListClientsParams struct {
Search string
Sort string
Desc bool
Limit int
Offset int
}

var clientSortColumns = map[string]string{
"name": "lower(u.given_name || ' ' || u.family_name)",
"created_at": "u.created_at",
}

func (s *Store) ListClients(ctx context.Context, p ListClientsParams) ([]Client, int, error) {
order, ok := clientSortColumns[p.Sort]
if !ok {
return nil, 0, fmt.Errorf("list clients: unknown sort %q", p.Sort)
}
if p.Desc {
order += " DESC"
}

rows, _ := s.pool.Query(ctx, `
SELECT u.id, u.email, u.given_name, u.family_name, u.created_at, count(*) OVER () AS total
FROM users u
WHERE strpos(lower(u.given_name || ' ' || u.family_name || ' ' || u.email), lower($1)) > 0
ORDER BY `+order+`, u.id
LIMIT $2 OFFSET $3`,
strings.TrimSpace(p.Search), p.Limit, p.Offset,
)

type clientRow struct {
Client
Total int `db:"total"`
}
result, err := pgx.CollectRows(rows, pgx.RowToStructByName[clientRow])
if err != nil {
return nil, 0, fmt.Errorf("list clients: %w", err)
}

clients := make([]Client, len(result))
total := 0
for i, row := range result {
clients[i] = row.Client
total = row.Total
}

return clients, total, nil
}
55 changes: 55 additions & 0 deletions internal/database/clients_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package database

import (
"context"
"slices"
"testing"
"time"

"github.com/mtiluk/naplata/internal/testdb"
)

func TestListClients(t *testing.T) {
pool := testdb.New(t)
store := NewStore(pool)

day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
testdb.InsertUser(t, pool, "Ada Lovelace", day)
testdb.InsertUser(t, pool, "Grace Hopper", day.AddDate(0, 0, 1))
testdb.InsertUser(t, pool, "Alan Turing", day.AddDate(0, 0, 2))

tests := []struct {
name string
p ListClientsParams
want []string
total int
}{
{"newest first", ListClientsParams{Sort: "created_at", Desc: true}, []string{"Alan", "Grace", "Ada"}, 3},
{"name ascending", ListClientsParams{Sort: "name"}, []string{"Ada", "Alan", "Grace"}, 3},
{"name descending", ListClientsParams{Sort: "name", Desc: true}, []string{"Grace", "Alan", "Ada"}, 3},
{"search ignores case", ListClientsParams{Sort: "name", Search: "LOVELACE"}, []string{"Ada"}, 1},
{"search email", ListClientsParams{Sort: "name", Search: "alan@"}, []string{"Alan"}, 1},
{"search is literal", ListClientsParams{Sort: "name", Search: "%"}, []string{}, 0},
{"pagination", ListClientsParams{Sort: "name", Limit: 2, Offset: 1}, []string{"Alan", "Grace"}, 3},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.p.Limit == 0 {
tt.p.Limit = 10
}
clients, total, err := store.ListClients(context.Background(), tt.p)
if err != nil {
t.Fatal(err)
}

got := []string{}
for _, c := range clients {
got = append(got, c.GivenName)
}
if !slices.Equal(got, tt.want) || total != tt.total {
t.Errorf("got %v (total %d), want %v (total %d)", got, total, tt.want, tt.total)
}
})
}
}
45 changes: 45 additions & 0 deletions internal/server/clients.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package server

import (
"log/slog"
"net/http"
"strconv"

"github.com/mtiluk/naplata/internal/database"
)

func (h *Handler) ListClientsEndpoint(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()

sort := query.Get("sort")
if sort != "name" {
sort = "created_at"
}
order := query.Get("order")
desc := order == "desc" || (order == "" && sort == "created_at")
page := intParam(query.Get("page"), 1, 1, 1_000_000)
perPage := intParam(query.Get("per_page"), 25, 1, 100)

clients, total, err := h.store.ListClients(r.Context(), database.ListClientsParams{
Search: query.Get("q"),
Sort: sort,
Desc: desc,
Limit: perPage,
Offset: (page - 1) * perPage,
})
if err != nil {
slog.Error("list clients", "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}

writeJSON(w, http.StatusOK, map[string]any{"clients": clients, "total": total})
}

func intParam(value string, fallback, lo, hi int) int {
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return max(lo, min(n, hi))
}
68 changes: 68 additions & 0 deletions internal/server/clients_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package server

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/alexedwards/scs/v2"
"github.com/mtiluk/naplata/internal/database"
"github.com/mtiluk/naplata/internal/permissions"
"github.com/mtiluk/naplata/internal/testdb"
)

func TestListClientsEndpoint(t *testing.T) {
pool := testdb.New(t)
store := database.NewStore(pool)
if err := store.SeedPermissions(context.Background(), permissions.All); err != nil {
t.Fatal(err)
}

day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
client := testdb.InsertUser(t, pool, "Ada Lovelace", day)
testdb.InsertUser(t, pool, "Grace Hopper", day.AddDate(0, 0, 1))
staff := testdb.InsertUser(t, pool, "Staff Admin", day.AddDate(0, 0, 2))
testdb.MakeStaff(t, pool, staff)

sm := scs.New()
h := NewHandler(store, sm)
route := h.RequirePermission(http.HandlerFunc(h.ListClientsEndpoint), permissions.ClientsView)

get := func(userID, query string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients"+query, nil)
ctx, _ := sm.Load(r.Context(), "")
if userID != "" {
sm.Put(ctx, sessionUserIDKey, userID)
}
w := httptest.NewRecorder()
route.ServeHTTP(w, r.WithContext(ctx))
return w
}

for _, tt := range []struct {
user string
code int
}{
{"", http.StatusUnauthorized},
{client, http.StatusForbidden},
{staff, http.StatusOK},
} {
if w := get(tt.user, ""); w.Code != tt.code {
t.Errorf("user %q: status %d, want %d", tt.user, w.Code, tt.code)
}
}

var body struct {
Clients []database.Client
Total int
}
if err := json.NewDecoder(get(staff, "?sort=name&page=2&per_page=1").Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Total != 3 || len(body.Clients) != 1 || body.Clients[0].GivenName != "Grace" {
t.Errorf("unexpected response: %+v", body)
}
}
83 changes: 83 additions & 0 deletions internal/testdb/testdb.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package testdb

import (
"context"
"os"
"strings"
"testing"
"time"

"github.com/jackc/pgx/v5/pgxpool"
"github.com/mtiluk/naplata/migrations"
)

const lockID = 4_284_102

func New(t *testing.T) *pgxpool.Pool {
t.Helper()

url := os.Getenv("NAPLATA_TEST_DATABASE_URL")
if url == "" {
t.Skip("NAPLATA_TEST_DATABASE_URL is not set")
}

ctx := context.Background()

pool, err := pgxpool.New(ctx, url)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)

lock, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("acquire lock connection: %v", err)
}
if _, err := lock.Exec(ctx, "SELECT pg_advisory_lock($1)", lockID); err != nil {
t.Fatalf("lock test database: %v", err)
}
t.Cleanup(func() {
lock.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", lockID)
lock.Release()
})

if err := migrations.Up(ctx, pool); err != nil {
t.Fatalf("migrate test database: %v", err)
}

if _, err := pool.Exec(ctx, "TRUNCATE users, roles, permissions, sessions RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("reset test database: %v", err)
}

return pool
}

func InsertUser(t *testing.T, pool *pgxpool.Pool, name string, createdAt time.Time) string {
t.Helper()

given, family, _ := strings.Cut(name, " ")
var id string
err := pool.QueryRow(context.Background(), `
INSERT INTO users (email, given_name, family_name, password_hash, created_at)
VALUES ($1, $2, $3, 'hash', $4)
RETURNING id`,
strings.ToLower(given)+"@example.com", given, family, createdAt,
).Scan(&id)
if err != nil {
t.Fatalf("insert user: %v", err)
}
return id
}

func MakeStaff(t *testing.T, pool *pgxpool.Pool, userID string) {
t.Helper()

_, err := pool.Exec(context.Background(), `
INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'admin'`,
userID,
)
if err != nil {
t.Fatalf("make staff: %v", err)
}
}
Loading
Loading