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 the same // rows, unlike games, which are private per user. None of the queries // below filter by user_id, because platform has no such column. Reading // the list only requires being logged in (addAuth); adding, renaming or // deleting a platform requires being an admin (addAdminAuth), since that // changes what everyone else sees. 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", } addAdminAuth(&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", } addAdminAuth(&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", } addAdminAuth(&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) }) }