Files
Completed/internal/api/routes.go
T
SansanandClaude Sonnet 5 7fbd403d30 Make the platform list shared across users; keep games private
Platforms no longer have a user_id: there's one global list, unique by
name, that every authenticated user reads from and writes to. Games are
unchanged - still private per user, still referencing platform_id.

- Migration: dropped platform.user_id and its FK, UNIQUE(user_id, name) ->
  UNIQUE(name), index on change_seq alone instead of (user_id, change_seq).
  Amended the one init migration in place rather than stacking a second one,
  since nothing is deployed yet.
- db/query.sql: platform queries no longer take a user_id parameter.
  DeletePlatform's NOT EXISTS guard already checked games from any user (it
  never filtered the subquery by user_id), so deleting a shared platform is
  correctly blocked by any user's games on it, not just the deleter's.
- routes.go: platform handlers still require auth (the list isn't public to
  anonymous requests) but no longer scope by user.
- Tests: updated the mocks for the new signatures, added
  TestListPlatformsIsSharedAcrossUsers.

Verified against a real Postgres: user A creates "PC", user B sees it
immediately, both can add games against it, each only sees their own
games, deleting the platform is blocked while either user has a game on
it, and the name stays globally unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 11:34:47 +02:00

343 lines
11 KiB
Go

package api
import (
"context"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"completed/internal/db"
"completed/internal/model"
)
func RegisterRoutes(api huma.API, queries db.Querier) {
// Root/Hello Route
huma.Register(api, huma.Operation{
OperationID: "get-hello",
Method: http.MethodGet,
Path: "/hello",
Summary: "Say Hello",
Description: "Returns a friendly hello message.",
}, func(ctx context.Context, input *struct{}) (*struct {
Body struct {
Message string `json:"message"`
}
}, error) {
resp := &struct {
Body struct {
Message string `json:"message"`
}
}{}
resp.Body.Message = "Hello from Huma + Echo!"
return resp, nil
})
registerAuthRoutes(api, queries)
registerPlatformRoutes(api, queries)
registerGameRoutes(api, queries)
}
// --- Platform Routes ---
//
// Platforms are a shared list: every authenticated user reads and writes
// the same rows, unlike games, which are private per user. Routes still
// require auth (so the list isn't public), but none of the queries below
// filter by user_id, because platform has no such column.
func registerPlatformRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
OperationID: "list-platforms",
Method: http.MethodGet,
Path: "/api/platforms",
Summary: "List Platforms",
}
addAuth(&listOp, api, queries)
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Platform }, error) {
platforms, err := queries.ListPlatforms(ctx)
if err != nil {
return nil, dbError(err)
}
dtos := make([]model.Platform, len(platforms))
for i, p := range platforms {
dtos[i] = model.Platform{ID: p.ID.String(), Name: p.Name}
}
return &struct{ Body []model.Platform }{Body: dtos}, nil
})
getOp := huma.Operation{
OperationID: "get-platform",
Method: http.MethodGet,
Path: "/api/platforms/{id}",
Summary: "Get Platform",
}
addAuth(&getOp, api, queries)
huma.Register(api, getOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{ Body model.Platform }, error) {
p, err := queries.GetPlatform(ctx, input.ID)
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Platform }{Body: model.Platform{ID: p.ID.String(), Name: p.Name}}, nil
})
createOp := huma.Operation{
OperationID: "create-platform",
Method: http.MethodPost,
Path: "/api/platforms",
Summary: "Create Platform",
}
addAuth(&createOp, api, queries)
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.PlatformInput }) (*struct{ Body model.Platform }, error) {
platform, err := queries.CreatePlatform(ctx, input.Body.Name)
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
})
updateOp := huma.Operation{
OperationID: "update-platform",
Method: http.MethodPut,
Path: "/api/platforms/{id}",
Summary: "Update Platform",
}
addAuth(&updateOp, api, queries)
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
Body model.PlatformInput
}) (*struct{ Body model.Platform }, error) {
platform, err := queries.UpdatePlatform(ctx, db.UpdatePlatformParams{
ID: input.ID,
Name: input.Body.Name,
})
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Platform }{Body: model.Platform{ID: platform.ID.String(), Name: platform.Name}}, nil
})
deleteOp := huma.Operation{
OperationID: "delete-platform",
Method: http.MethodDelete,
Path: "/api/platforms/{id}",
Summary: "Delete Platform",
}
addAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
// Checked separately so a 0-rows result from DeletePlatform below can
// only mean "blocked by games referencing it", not "doesn't exist" -
// soft delete means the FK alone can't tell the two apart (see
// DeletePlatform's comment in db/query.sql). Accepts a race where
// the platform is deleted between these two queries; harmless, since
// the second query would then just affect 0 rows and report 409
// instead of the 404 a plain retry would hit anyway.
if _, err := queries.GetPlatform(ctx, input.ID); err != nil {
return nil, dbError(err)
}
rows, err := queries.DeletePlatform(ctx, input.ID)
if err != nil {
return nil, dbError(err)
}
if rows == 0 {
return nil, huma.Error409Conflict("platform still has games; delete or reassign them first")
}
return nil, nil
})
}
// --- Game Routes ---
// formatDate and parseDate convert between the API's YYYY-MM-DD strings and
// pgtype.Date. CreateGame, GetGame, ListGames and UpdateGame each return a
// distinct sqlc row type with identical fields (id/name/platform_id/
// platform_name/score/release_date/finished), so the DTO conversion itself
// is written out at each call site rather than factored into one function.
func formatDate(d pgtype.Date) string {
if !d.Valid {
return ""
}
return d.Time.Format("2006-01-02")
}
// parseDate parses a YYYY-MM-DD string into a pgtype.Date. An empty string
// parses to an invalid/NULL date, which is valid for the optional
// "finished" field but rejected separately for the required release_date.
func parseDate(s string) (pgtype.Date, error) {
if s == "" {
return pgtype.Date{}, nil
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return pgtype.Date{}, err
}
return pgtype.Date{Time: t, Valid: true}, nil
}
func registerGameRoutes(api huma.API, queries db.Querier) {
listOp := huma.Operation{
OperationID: "list-games",
Method: http.MethodGet,
Path: "/api/games",
Summary: "List Games",
}
addAuth(&listOp, api, queries)
huma.Register(api, listOp, func(ctx context.Context, input *struct{}) (*struct{ Body []model.Game }, error) {
games, err := queries.ListGames(ctx, userFromContext(ctx).ID)
if err != nil {
return nil, dbError(err)
}
dtos := make([]model.Game, len(games))
for i, g := range games {
dtos[i] = model.Game{
ID: g.ID.String(),
Name: g.Name,
PlatformID: g.PlatformID.String(),
PlatformName: g.PlatformName,
Score: g.Score,
ReleaseDate: formatDate(g.ReleaseDate),
Finished: formatDate(g.Finished),
}
}
return &struct{ Body []model.Game }{Body: dtos}, nil
})
getOp := huma.Operation{
OperationID: "get-game",
Method: http.MethodGet,
Path: "/api/games/{id}",
Summary: "Get Game",
}
addAuth(&getOp, api, queries)
huma.Register(api, getOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{ Body model.Game }, error) {
g, err := queries.GetGame(ctx, db.GetGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Game }{Body: model.Game{
ID: g.ID.String(),
Name: g.Name,
PlatformID: g.PlatformID.String(),
PlatformName: g.PlatformName,
Score: g.Score,
ReleaseDate: formatDate(g.ReleaseDate),
Finished: formatDate(g.Finished),
}}, nil
})
createOp := huma.Operation{
OperationID: "create-game",
Method: http.MethodPost,
Path: "/api/games",
Summary: "Create Game",
}
addAuth(&createOp, api, queries)
huma.Register(api, createOp, func(ctx context.Context, input *struct{ Body model.GameInput }) (*struct{ Body model.Game }, error) {
platformID, err := uuid.Parse(input.Body.PlatformID)
if err != nil {
return nil, huma.Error400BadRequest("invalid platform_id")
}
ry, err := parseDate(input.Body.ReleaseDate)
if err != nil || !ry.Valid {
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
}
fin, err := parseDate(input.Body.Finished)
if err != nil {
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
}
game, err := queries.CreateGame(ctx, db.CreateGameParams{
UserID: userFromContext(ctx).ID,
PlatformID: platformID,
Name: input.Body.Name,
Score: input.Body.Score,
ReleaseDate: ry,
Finished: fin,
})
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Game }{Body: model.Game{
ID: game.ID.String(),
Name: game.Name,
PlatformID: game.PlatformID.String(),
PlatformName: game.PlatformName,
Score: game.Score,
ReleaseDate: formatDate(game.ReleaseDate),
Finished: formatDate(game.Finished),
}}, nil
})
updateOp := huma.Operation{
OperationID: "update-game",
Method: http.MethodPut,
Path: "/api/games/{id}",
Summary: "Update Game",
}
addAuth(&updateOp, api, queries)
huma.Register(api, updateOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
Body model.GameInput
}) (*struct{ Body model.Game }, error) {
platformID, err := uuid.Parse(input.Body.PlatformID)
if err != nil {
return nil, huma.Error400BadRequest("invalid platform_id")
}
ry, err := parseDate(input.Body.ReleaseDate)
if err != nil || !ry.Valid {
return nil, huma.Error400BadRequest("invalid or missing release_date (expected YYYY-MM-DD)")
}
fin, err := parseDate(input.Body.Finished)
if err != nil {
return nil, huma.Error400BadRequest("invalid finished date (expected YYYY-MM-DD)")
}
game, err := queries.UpdateGame(ctx, db.UpdateGameParams{
ID: input.ID,
UserID: userFromContext(ctx).ID,
PlatformID: platformID,
Name: input.Body.Name,
Score: input.Body.Score,
ReleaseDate: ry,
Finished: fin,
})
if err != nil {
return nil, dbError(err)
}
return &struct{ Body model.Game }{Body: model.Game{
ID: game.ID.String(),
Name: game.Name,
PlatformID: game.PlatformID.String(),
PlatformName: game.PlatformName,
Score: game.Score,
ReleaseDate: formatDate(game.ReleaseDate),
Finished: formatDate(game.Finished),
}}, nil
})
deleteOp := huma.Operation{
OperationID: "delete-game",
Method: http.MethodDelete,
Path: "/api/games/{id}",
Summary: "Delete Game",
}
addAuth(&deleteOp, api, queries)
huma.Register(api, deleteOp, func(ctx context.Context, input *struct {
ID uuid.UUID `path:"id"`
}) (*struct{}, error) {
rows, err := queries.DeleteGame(ctx, db.DeleteGameParams{ID: input.ID, UserID: userFromContext(ctx).ID})
if err != nil {
return nil, dbError(err)
}
return nil, notFoundIfZero(rows)
})
}