Compare commits
11 Commits
e482925294
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ed0174ec | |||
| db24283037 | |||
| ccc1b0505f | |||
| b91725f53f | |||
| 82a71d6490 | |||
| 957ca4aec6 | |||
| f98b7b2d88 | |||
| ea2f82ba9e | |||
| fc7f5665e7 | |||
| 3666870111 | |||
| 723a213be3 |
644
REVIEW_PLAN.md
644
REVIEW_PLAN.md
@@ -1,644 +0,0 @@
|
||||
# oslstats Codebase Review — Implementation Plan
|
||||
|
||||
## Dependency Order Rationale
|
||||
|
||||
Changes are ordered so that **foundational/infrastructure changes come first**, which later changes depend on. This prevents doing work twice or having to revisit files.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation — Establish Response System Rules
|
||||
|
||||
Before fixing individual handlers, establish clear rules about when to use each response system. This is the root cause of most inconsistencies.
|
||||
|
||||
### 1.1 Document the three response systems
|
||||
|
||||
Create a comment block or doc at the top of each package (or a shared document) defining when to use each:
|
||||
|
||||
| System | When to use | Example |
|
||||
|--------|-------------|---------|
|
||||
| **`throw`** | Full-page navigation (GET requests), or errors so severe the page can't render | `throw.NotFound`, `throw.BadRequest` |
|
||||
| **`notify`** | HTMX partials/modal actions where you want a WebSocket toast notification | `notify.Warn`, `notify.Success` |
|
||||
| **`respond`** | Pure API-style responses (JSON body, no page render, no toast) — uniqueness checks, programmatic callers | `respond.BadRequest`, `respond.OK` |
|
||||
|
||||
**Rule of thumb**: If the handler renders templ → `throw` for errors. If it's an HTMX action → `notify`. If it's a pure data endpoint → `respond`.
|
||||
|
||||
### 1.2 Remove duplicate `parseErrorDetails`
|
||||
|
||||
**File**: `internal/handlers/errors.go` (lines 14-29)
|
||||
|
||||
This function is an exact duplicate of `notify.ParseErrorDetails` in `internal/notify/util.go` (lines 29-44).
|
||||
|
||||
**Fix**: Delete `parseErrorDetails` from `handlers/errors.go` and update any callers to use `notify.ParseErrorDetails` instead.
|
||||
|
||||
**Check for callers first:**
|
||||
|
||||
```
|
||||
grep -rn "parseErrorDetails" internal/handlers/
|
||||
```
|
||||
|
||||
If no callers exist (the function is unexported and may be dead code), simply delete it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Transaction Misuse — Read-only operations using write transactions
|
||||
|
||||
`WithNotifyTx` always commits (it's a write transaction with notify-based error handling). Several handlers use it for **read-only** operations. This should be `WithReadTx` (which always rolls back).
|
||||
|
||||
However, there's a missing helper: a `WithNotifyReadTx` that uses `notify` for errors instead of `throw`. You need to decide:
|
||||
|
||||
**Option A**: Add a `WithNotifyReadTx` helper to `internal/db/txhelpers.go`
|
||||
**Option B**: Convert these to `WithReadTx` and accept that read-only errors render error pages
|
||||
|
||||
Recommendation: **Option A** — add `WithNotifyReadTx`.
|
||||
|
||||
### 2.1 Add `WithNotifyReadTx` to `internal/db/txhelpers.go`
|
||||
|
||||
After `WithReadTx` (line 38), add:
|
||||
|
||||
```go
|
||||
// WithNotifyReadTx executes a read-only transaction with notification-based error handling
|
||||
// Uses notify.InternalServiceError instead of throw.InternalServiceError
|
||||
func (db *DB) WithNotifyReadTx(
|
||||
s *hws.Server,
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
fn TxFunc,
|
||||
) bool {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
ok, err := db.withTx(ctx, fn, false)
|
||||
if err != nil {
|
||||
notify.InternalServiceError(s, w, r, "Database error", err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Fix read-only handlers using `WithNotifyTx`
|
||||
|
||||
These handlers only **read** data but use `WithNotifyTx` (write transaction):
|
||||
|
||||
| File | Line | Handler | Fix |
|
||||
|------|------|---------|-----|
|
||||
| `admin_roles.go` | 117 | `AdminRoleManage` | → `WithNotifyReadTx` |
|
||||
| `admin_roles.go` | 147 | `AdminRoleDeleteConfirm` | → `WithNotifyReadTx` |
|
||||
| `admin_roles.go` | 238 | `AdminRolePermissionsModal` | → `WithNotifyReadTx` |
|
||||
| `isunique.go` | 36 | `IsUnique` | → `WithNotifyReadTx` |
|
||||
|
||||
For each, simply change `conn.WithNotifyTx` to `conn.WithNotifyReadTx`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Double Response / Redundant Error Handling Bugs
|
||||
|
||||
These are actual bugs where two HTTP responses are sent to the same request.
|
||||
|
||||
### 3.1 `fixtures.go:71` — Double response after `ValidateAndNotify`
|
||||
|
||||
**File**: `internal/handlers/fixtures.go`, lines 70-72
|
||||
|
||||
```go
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
w.WriteHeader(http.StatusBadRequest) // ← BUG: ValidateAndNotify already sent notifications
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Remove line 71 (`w.WriteHeader(http.StatusBadRequest)`). The `ValidateAndNotify` already handles the response via WebSocket.
|
||||
|
||||
### 3.2 `fixtures.go:100-103` — `respond.BadRequest` then `return false, errors.Wrap(...)`
|
||||
|
||||
**File**: `internal/handlers/fixtures.go`, lines 100-103
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
|
||||
}
|
||||
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks") // ← runs for BOTH bad request AND other errors
|
||||
```
|
||||
|
||||
The `return false, errors.Wrap(...)` runs unconditionally. If it's a bad request, `respond.BadRequest` fires, then `return false, err` causes `WithNotifyTx` to also call `notify.InternalServiceError`.
|
||||
|
||||
**Fix**: Add `return false, nil` after `respond.BadRequest`:
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks"))
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.UpdateFixtureGameWeeks")
|
||||
```
|
||||
|
||||
### 3.3 `season_leagues.go:80-83` — Same pattern
|
||||
|
||||
**File**: `internal/handlers/season_leagues.go`, lines 80-83
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
respond.BadRequest(w, err)
|
||||
}
|
||||
return false, errors.Wrap(err, "season.RemoveLeague")
|
||||
```
|
||||
|
||||
**Fix**: Add `return false, nil` after `respond.BadRequest`:
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
respond.BadRequest(w, err)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "season.RemoveLeague")
|
||||
```
|
||||
|
||||
### 3.4 `team_roster_manage.go:25-26` and `35-37` — Redundant responses
|
||||
|
||||
**File**: `internal/handlers/team_roster_manage.go`
|
||||
|
||||
```go
|
||||
// Line 23-26:
|
||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||
if !ok {
|
||||
respond.BadRequest(w, errors.New("failed to parse form")) // ← redundant
|
||||
return
|
||||
}
|
||||
|
||||
// Line 35-37:
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
respond.BadRequest(w, errors.New("invalid form data")) // ← redundant
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`ParseFormOrNotify` and `ValidateAndNotify` already handle the response via notifications. The extra `respond.BadRequest` sends a second response.
|
||||
|
||||
**Fix**: Remove both `respond.BadRequest` calls:
|
||||
|
||||
```go
|
||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// ...
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 `free_agents.go:94-96` and `100-102` — Same redundant pattern
|
||||
|
||||
**File**: `internal/handlers/free_agents.go`, `RegisterFreeAgent` handler
|
||||
|
||||
```go
|
||||
// Line 93-96:
|
||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||
if !ok {
|
||||
respond.BadRequest(w, errors.New("failed to parse form")) // ← redundant
|
||||
return
|
||||
}
|
||||
|
||||
// Line 99-102:
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
respond.BadRequest(w, errors.New("invalid form data")) // ← redundant
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Same as 3.4 — remove both `respond.BadRequest` calls.
|
||||
|
||||
Also apply the same fix in the `UnregisterFreeAgent` handler (lines 167-174), which has the identical pattern.
|
||||
|
||||
### 3.6 `login.go:37-39` — `notify` then `respond.OK`
|
||||
|
||||
**File**: `internal/handlers/login.go`, lines 36-39
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
|
||||
respond.OK(w) // ← sends 200 OK after the notification
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
The `notify.ServiceUnavailable` sends a WebSocket message. Then `respond.OK` writes a 200 status. The 200 status tells HTMX the request succeeded, which is misleading.
|
||||
|
||||
**Fix**: Remove `respond.OK(w)`. The notification alone is sufficient for the HTMX POST handler:
|
||||
|
||||
```go
|
||||
if err != nil {
|
||||
notify.ServiceUnavailable(s, w, r, "Login currently unavailable", err)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Raw Response Patterns → Use Proper Helpers
|
||||
|
||||
### 4.1 `season_league_add_team.go:42` — Raw `w.WriteHeader`
|
||||
|
||||
**File**: `internal/handlers/season_league_add_team.go`, line 42
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
w.WriteHeader(http.StatusBadRequest) // ← raw header, no error body
|
||||
return false, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Use `notify.Warn` since this is inside a `WithNotifyTx` context:
|
||||
|
||||
```go
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Invalid Request", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `season_league_add_team.go:53-54` — Manual HX-Redirect
|
||||
|
||||
**File**: `internal/handlers/season_league_add_team.go`, lines 53-54
|
||||
|
||||
```go
|
||||
w.Header().Set("HX-Redirect", fmt.Sprintf("/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
```
|
||||
|
||||
**Fix**: Use `respond.HXRedirect`:
|
||||
|
||||
```go
|
||||
respond.HXRedirect(w, "/seasons/%s/leagues/%s/teams", season.ShortName, league.ShortName)
|
||||
```
|
||||
|
||||
This will also require adding `"git.haelnorr.com/h/oslstats/internal/respond"` to the imports and potentially removing `"fmt"` if no longer used.
|
||||
|
||||
### 4.3 `admin_roles.go:142` — Raw `w.WriteHeader`
|
||||
|
||||
**File**: `internal/handlers/admin_roles.go`, line 142
|
||||
|
||||
```go
|
||||
roleID, err := strconv.Atoi(roleIDStr)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest) // ← raw header
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Use `respond.BadRequest`:
|
||||
|
||||
```go
|
||||
roleID, err := strconv.Atoi(roleIDStr)
|
||||
if err != nil {
|
||||
respond.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: `strconv.Atoi` Error Handling Standardization
|
||||
|
||||
Currently, `strconv.Atoi` failures for path parameters are handled inconsistently:
|
||||
|
||||
| Pattern | Used in |
|
||||
|---------|---------|
|
||||
| `throw.BadRequest(s, w, r, "Invalid X ID", err)` | `fixture_detail.go` (x15), `forfeit.go`, `fixture_result.go` (x4), `free_agents.go` (x2), `admin_preview_role.go`, `admin_audit.go` |
|
||||
| `throw.NotFound(s, w, r, r.URL.Path)` | `team_detail.go`, `season_league_team_detail.go`, `player_view.go`, `player_link_slapid.go` |
|
||||
| `respond.BadRequest(w, err)` | `admin_roles.go` (x4), `fixtures.go:121` |
|
||||
| `w.WriteHeader(http.StatusBadRequest)` | `admin_roles.go:142` |
|
||||
|
||||
**Recommended standard**: For path parameters in URL routing:
|
||||
- If it's a page navigation (user-visible URL) → `throw.NotFound` (an invalid ID in a URL means the resource doesn't exist)
|
||||
- If it's an HTMX action endpoint → `respond.BadRequest` (the ID came from the UI, so it's a programming error or tampered request)
|
||||
|
||||
Apply this consistently:
|
||||
|
||||
### Files to update:
|
||||
|
||||
**To `throw.NotFound`** (page navigation handlers):
|
||||
- No changes needed — `team_detail.go`, `season_league_team_detail.go`, `player_view.go` already do this correctly
|
||||
|
||||
**To `respond.BadRequest`** (HTMX action endpoints):
|
||||
- `admin_roles.go:110` — Already uses `respond.BadRequest` ✓
|
||||
- `admin_roles.go:170`, `227`, `291` — Already use `respond.BadRequest` ✓
|
||||
- `admin_roles.go:142` — Fixed in Phase 4.3
|
||||
|
||||
**Mixed handlers** (both page navigation and HTMX actions):
|
||||
- `fixture_detail.go` — The page-rendering handlers use `throw.BadRequest`. Since these are often navigated to via URL, consider changing to `throw.NotFound` for the "page" handlers, and keeping `throw.BadRequest` for the action handlers. This is a judgment call — at minimum, be consistent within the file.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: `sql.ErrNoRows` Comparison Fix
|
||||
|
||||
### 6.1 `fixture.go:51` — String comparison for `sql.ErrNoRows`
|
||||
|
||||
**File**: `internal/db/fixture.go`, line 51
|
||||
|
||||
```go
|
||||
if err.Error() == "sql: no rows in result set" {
|
||||
return false, 0, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Fix**: Use proper error comparison:
|
||||
|
||||
```go
|
||||
import "database/sql"
|
||||
// ...
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, 0, nil
|
||||
}
|
||||
```
|
||||
|
||||
This requires adding `"database/sql"` to imports (and `"github.com/pkg/errors"` may already handle `Is` — check if you need to also import the standard `errors` package or if `pkg/errors` supports `Is`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Code Style Fixes
|
||||
|
||||
### 7.1 `season.go:70` — `errors.WithMessage` instead of `errors.Wrap`
|
||||
|
||||
**File**: `internal/db/season.go`, line 70
|
||||
|
||||
```go
|
||||
return nil, errors.WithMessage(err, "db.Insert")
|
||||
```
|
||||
|
||||
**Fix**: Change to `errors.Wrap`:
|
||||
|
||||
```go
|
||||
return nil, errors.Wrap(err, "db.Insert")
|
||||
```
|
||||
|
||||
`errors.WithMessage` doesn't capture a new stack frame; `errors.Wrap` does. The codebase convention is `errors.Wrap`.
|
||||
|
||||
### 7.2 `register.go:67` — Wrong function name in error wrap
|
||||
|
||||
**File**: `internal/handlers/register.go`, line 67
|
||||
|
||||
```go
|
||||
return false, errors.Wrap(err, "db.IsUsernameUnique")
|
||||
```
|
||||
|
||||
The actual function called is `db.IsUnique` (line 65). The wrap message references a non-existent function.
|
||||
|
||||
**Fix**:
|
||||
|
||||
```go
|
||||
return false, errors.Wrap(err, "db.IsUnique")
|
||||
```
|
||||
|
||||
### 7.3 `fixtures.go:177` — Profanity in comment
|
||||
|
||||
**File**: `internal/handlers/fixtures.go`, line 177
|
||||
|
||||
```go
|
||||
// fuck i hate pointers sometimes
|
||||
```
|
||||
|
||||
**Fix**: Remove or rephrase:
|
||||
|
||||
```go
|
||||
// Pointer handling for optional game week values
|
||||
```
|
||||
|
||||
### 7.4 `register.go:63` — Audit with nil user
|
||||
|
||||
**File**: `internal/handlers/register.go`, line 63
|
||||
|
||||
```go
|
||||
var user *db.User
|
||||
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user) // user is nil here
|
||||
```
|
||||
|
||||
This passes a nil user to `NewAudit` before the user exists. The audit will have no user associated. This is somewhat intentional (registration happens before the user exists), but it's worth documenting:
|
||||
|
||||
**Fix**: Add a comment explaining the intentional nil:
|
||||
|
||||
```go
|
||||
var user *db.User
|
||||
// User is nil before registration — audit tracks the request source (IP/UA) only
|
||||
audit := db.NewAudit(r.RemoteAddr, r.UserAgent(), user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Validation Interface Gap
|
||||
|
||||
### 8.1 `TimeInLocation` missing from `Getter` interface
|
||||
|
||||
**File**: `internal/validation/validation.go`, lines 22-38
|
||||
|
||||
The `Getter` interface defines `Time(key, format)` but not `TimeInLocation(key, format, loc)`. The `FormGetter` has `TimeInLocation` (line 95 of `forms.go`), but `QueryGetter` doesn't, and neither does the interface.
|
||||
|
||||
**Fix**: Add `TimeInLocation` to the `Getter` interface and to `QueryGetter`:
|
||||
|
||||
In `validation.go`, add to the `Getter` interface:
|
||||
|
||||
```go
|
||||
TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField
|
||||
```
|
||||
|
||||
In `querys.go`, add the method:
|
||||
|
||||
```go
|
||||
func (q *QueryGetter) TimeInLocation(key string, format *timefmt.Format, loc *time.Location) *TimeField {
|
||||
return newTimeFieldInLocation(key, format, loc, q)
|
||||
}
|
||||
```
|
||||
|
||||
Also add `"time"` to the imports of `querys.go`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Duplicate Code Consolidation
|
||||
|
||||
### 9.1 Duplicate leaderboard computation pattern
|
||||
|
||||
The same pattern appears in **5 places**:
|
||||
|
||||
1. `handlers/index.go:54-69`
|
||||
2. `handlers/season_league_table.go:40-55`
|
||||
3. `handlers/season_league_team_detail.go:87-100`
|
||||
4. `db/team.go:126-142`
|
||||
5. `db/match_preview.go:222`
|
||||
|
||||
The pattern is:
|
||||
```go
|
||||
fixtures, err := db.GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
|
||||
fixtureIDs := make([]int, len(fixtures))
|
||||
for i, f := range fixtures { fixtureIDs[i] = f.ID }
|
||||
resultMap, err := db.GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||
leaderboard := db.ComputeLeaderboard(teams, fixtures, resultMap)
|
||||
```
|
||||
|
||||
**Fix**: Create a helper function in `internal/db/` (e.g. in `fixture_result.go` near `ComputeLeaderboard`):
|
||||
|
||||
```go
|
||||
// GetLeaderboard fetches fixtures and results, then computes the leaderboard for a season+league.
|
||||
func GetLeaderboard(ctx context.Context, tx bun.Tx, seasonID, leagueID int, teams []*Team) ([]*LeaderboardEntry, error) {
|
||||
fixtures, err := GetAllocatedFixtures(ctx, tx, seasonID, leagueID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetAllocatedFixtures")
|
||||
}
|
||||
fixtureIDs := make([]int, len(fixtures))
|
||||
for i, f := range fixtures {
|
||||
fixtureIDs[i] = f.ID
|
||||
}
|
||||
resultMap, err := GetFinalizedResultsForFixtures(ctx, tx, fixtureIDs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "GetFinalizedResultsForFixtures")
|
||||
}
|
||||
return ComputeLeaderboard(teams, fixtures, resultMap), nil
|
||||
}
|
||||
```
|
||||
|
||||
Then replace all 5 occurrences with a single call:
|
||||
|
||||
```go
|
||||
leaderboard, err := db.GetLeaderboard(ctx, tx, seasonID, leagueID, teams)
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.GetLeaderboard")
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Duplicate player stats SQL
|
||||
|
||||
**File**: `internal/db/player.go`, lines 116-206
|
||||
|
||||
Three functions — `GetPlayerAllTimeStats`, `GetPlayerStatsBySeason`, `GetPlayerStatsByTeam` — share 90% identical SQL. Only the `WHERE` clause differs.
|
||||
|
||||
**Fix**: Extract a common base function:
|
||||
|
||||
```go
|
||||
func getPlayerStats(ctx context.Context, tx bun.Tx, playerID int, extraJoin string, extraWhere string, extraArgs ...any) (*PlayerAllTimeStats, error) {
|
||||
if playerID == 0 {
|
||||
return nil, errors.New("playerID not provided")
|
||||
}
|
||||
stats := new(PlayerAllTimeStats)
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(DISTINCT frps.fixture_result_id) AS games_played,
|
||||
COALESCE(SUM(frps.periods_played), 0) AS total_periods_played,
|
||||
COALESCE(SUM(frps.goals), 0) AS total_goals,
|
||||
COALESCE(SUM(frps.assists), 0) AS total_assists,
|
||||
COALESCE(SUM(frps.saves), 0) AS total_saves,
|
||||
COALESCE(SUM(frps.shots), 0) AS total_shots,
|
||||
COALESCE(SUM(frps.blocks), 0) AS total_blocks,
|
||||
COALESCE(SUM(frps.passes), 0) AS total_passes
|
||||
FROM fixture_result_player_stats frps
|
||||
JOIN fixture_results fr ON fr.id = frps.fixture_result_id
|
||||
` + extraJoin + `
|
||||
WHERE fr.finalized = true
|
||||
AND frps.player_id = ?
|
||||
AND frps.period_num = 3
|
||||
` + extraWhere
|
||||
|
||||
args := append([]any{playerID}, extraArgs...)
|
||||
err := tx.NewRaw(query, args...).Scan(ctx, stats)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tx.NewRaw")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func GetPlayerAllTimeStats(ctx context.Context, tx bun.Tx, playerID int) (*PlayerAllTimeStats, error) {
|
||||
return getPlayerStats(ctx, tx, playerID, "", "")
|
||||
}
|
||||
|
||||
func GetPlayerStatsBySeason(ctx context.Context, tx bun.Tx, playerID, seasonID int) (*PlayerAllTimeStats, error) {
|
||||
if seasonID == 0 {
|
||||
return nil, errors.New("seasonID not provided")
|
||||
}
|
||||
return getPlayerStats(ctx, tx, playerID,
|
||||
"JOIN fixtures f ON f.id = fr.fixture_id",
|
||||
"AND f.season_id = ?", seasonID)
|
||||
}
|
||||
|
||||
func GetPlayerStatsByTeam(ctx context.Context, tx bun.Tx, playerID, teamID int) (*PlayerAllTimeStats, error) {
|
||||
if teamID == 0 {
|
||||
return nil, errors.New("teamID not provided")
|
||||
}
|
||||
return getPlayerStats(ctx, tx, playerID,
|
||||
"",
|
||||
"AND frps.team_id = ?", teamID)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Pattern Consistency (Lower Priority)
|
||||
|
||||
### 10.1 Leagues list page — missing GET/POST pattern and pagination
|
||||
|
||||
**File**: `internal/handlers/leagues_list.go`
|
||||
|
||||
Unlike `seasons_list.go` and `teams_list.go`, this handler:
|
||||
1. Always renders the full page (no GET vs POST check)
|
||||
2. Fetches ALL leagues without pagination
|
||||
|
||||
**Fix**: Follow the established pattern from `seasons_list.go`:
|
||||
|
||||
```go
|
||||
func LeaguesList(s *hws.Server, conn *db.DB) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
pageOpts, ok := db.GetPageOpts(s, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var leagues *db.List[db.League]
|
||||
if ok := conn.WithReadTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
var err error
|
||||
leagues, err = db.ListLeagues(ctx, tx, pageOpts) // New paginated function
|
||||
if err != nil {
|
||||
return false, errors.Wrap(err, "db.ListLeagues")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
if r.Method == "GET" {
|
||||
renderSafely(leaguesview.ListPage(leagues), s, r, w)
|
||||
} else {
|
||||
renderSafely(leaguesview.LeaguesList(leagues), s, r, w)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
This requires:
|
||||
- Adding a `ListLeagues` function in `internal/db/league.go` using `GetList[League](tx).GetPaged(ctx, pageOpts, defaults)`
|
||||
- Updating the template to support the new paginated list pattern
|
||||
- Adding a `LeaguesList` partial template
|
||||
|
||||
**Note**: This is a more involved change and may warrant its own feature task.
|
||||
|
||||
---
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
| # | Phase | Issue | Severity | Files |
|
||||
|---|-------|-------|----------|-------|
|
||||
| 1.2 | Foundation | Duplicate `parseErrorDetails` | Low | `handlers/errors.go` |
|
||||
| 2.1 | Transactions | Add `WithNotifyReadTx` | Medium | `db/txhelpers.go` |
|
||||
| 2.2 | Transactions | Fix 4 read-only handlers | Medium | `admin_roles.go`, `isunique.go` |
|
||||
| 3.1 | Double Response | `ValidateAndNotify` + `WriteHeader` | **High** | `fixtures.go:71` |
|
||||
| 3.2 | Double Response | `respond.BadRequest` + `return err` | **High** | `fixtures.go:100-103` |
|
||||
| 3.3 | Double Response | `respond.BadRequest` + `return err` | **High** | `season_leagues.go:80-83` |
|
||||
| 3.4 | Double Response | Redundant respond after notify | **High** | `team_roster_manage.go:25,36` |
|
||||
| 3.5 | Double Response | Redundant respond after notify | **High** | `free_agents.go:95,101,168,174` |
|
||||
| 3.6 | Double Response | `notify` then `respond.OK` | **High** | `login.go:38` |
|
||||
| 4.1 | Raw Responses | Raw `w.WriteHeader` | Medium | `season_league_add_team.go:42` |
|
||||
| 4.2 | Raw Responses | Manual HX-Redirect | Low | `season_league_add_team.go:53-54` |
|
||||
| 4.3 | Raw Responses | Raw `w.WriteHeader` | Medium | `admin_roles.go:142` |
|
||||
| 5 | Standardization | `strconv.Atoi` error handling | Low | Multiple files |
|
||||
| 6.1 | Bug Fix | String comparison for `sql.ErrNoRows` | **High** | `db/fixture.go:51` |
|
||||
| 7.1 | Style | `errors.WithMessage` → `errors.Wrap` | Low | `db/season.go:70` |
|
||||
| 7.2 | Style | Wrong wrap message | Low | `register.go:67` |
|
||||
| 7.3 | Style | Profanity in comment | Low | `fixtures.go:177` |
|
||||
| 7.4 | Style | Nil user audit — add comment | Low | `register.go:63` |
|
||||
| 8.1 | Interface | `TimeInLocation` missing from `Getter` | Medium | `validation/validation.go`, `querys.go` |
|
||||
| 9.1 | Duplication | Leaderboard computation | Medium | 5 files |
|
||||
| 9.2 | Duplication | Player stats SQL | Medium | `db/player.go` |
|
||||
| 10.1 | Consistency | Leagues list pattern | Low | `leagues_list.go`, `db/league.go`, templates |
|
||||
|
||||
**Recommended execution order**: Phases 1 → 2 → 3 → 6 → 4 → 7 → 8 → 9 → 5 → 10
|
||||
71
internal/db/migrations/20260316140000_add_series_forfeit.go
Normal file
71
internal/db/migrations/20260316140000_add_series_forfeit.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Migrations.MustRegister(
|
||||
// UP migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
// Add is_forfeit column
|
||||
_, err := conn.NewAddColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("is_forfeit BOOLEAN NOT NULL DEFAULT false").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add forfeit_team_id column
|
||||
_, err = conn.NewAddColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("forfeit_team_id INTEGER REFERENCES teams(id)").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add forfeit_reason column
|
||||
_, err = conn.NewAddColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("forfeit_reason VARCHAR").
|
||||
IfNotExists().
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
// DOWN migration
|
||||
func(ctx context.Context, conn *bun.DB) error {
|
||||
_, err := conn.NewDropColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("forfeit_reason").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("forfeit_team_id").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = conn.NewDropColumn().
|
||||
Model((*db.PlayoffSeries)(nil)).
|
||||
ColumnExpr("is_forfeit").
|
||||
Exec(ctx)
|
||||
return err
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -81,12 +81,18 @@ type PlayoffSeries struct {
|
||||
LoserNextSlot *string `bun:"loser_next_slot"` // "team1" or "team2" in loser's next series
|
||||
CreatedAt int64 `bun:",notnull"`
|
||||
|
||||
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
|
||||
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
|
||||
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
|
||||
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
|
||||
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
|
||||
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
|
||||
// Forfeit-related fields
|
||||
IsForfeit bool `bun:"is_forfeit,default:false"`
|
||||
ForfeitTeamID *int `bun:"forfeit_team_id"` // Which team forfeited
|
||||
ForfeitReason *string `bun:"forfeit_reason"` // Admin-provided reason
|
||||
|
||||
Bracket *PlayoffBracket `bun:"rel:belongs-to,join:bracket_id=id"`
|
||||
Team1 *Team `bun:"rel:belongs-to,join:team1_id=id"`
|
||||
Team2 *Team `bun:"rel:belongs-to,join:team2_id=id"`
|
||||
Winner *Team `bun:"rel:belongs-to,join:winner_team_id=id"`
|
||||
Loser *Team `bun:"rel:belongs-to,join:loser_team_id=id"`
|
||||
ForfeitTeam *Team `bun:"rel:belongs-to,join:forfeit_team_id=id"`
|
||||
Matches []*PlayoffMatch `bun:"rel:has-many,join:id=series_id"`
|
||||
}
|
||||
|
||||
// PlayoffMatch represents a single game within a series
|
||||
@@ -344,6 +350,7 @@ func GetPlayoffSeriesByID(
|
||||
Relation("Team2").
|
||||
Relation("Winner").
|
||||
Relation("Loser").
|
||||
Relation("ForfeitTeam").
|
||||
Relation("Matches", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||
return q.Order("pm.match_number ASC")
|
||||
}).
|
||||
|
||||
@@ -329,6 +329,149 @@ func DeleteSeriesResults(
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForfeitSeries forfeits a playoff series. The forfeiting team loses and the opponent
|
||||
// is declared the winner and advances through the bracket. Any existing match results
|
||||
// and fixtures are discarded. The series score (Team1Wins/Team2Wins) is left as-is.
|
||||
func ForfeitSeries(
|
||||
ctx context.Context,
|
||||
tx bun.Tx,
|
||||
seriesID int,
|
||||
forfeitTeamID int,
|
||||
reason string,
|
||||
audit *AuditMeta,
|
||||
) error {
|
||||
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetPlayoffSeriesByID")
|
||||
}
|
||||
if series == nil {
|
||||
return BadRequest("series not found")
|
||||
}
|
||||
|
||||
if series.Status == SeriesStatusCompleted {
|
||||
return BadRequest("series is already completed")
|
||||
}
|
||||
if series.Status == SeriesStatusBye {
|
||||
return BadRequest("cannot forfeit a bye series")
|
||||
}
|
||||
if series.Team1ID == nil || series.Team2ID == nil {
|
||||
return BadRequest("both teams must be assigned to forfeit a series")
|
||||
}
|
||||
|
||||
// Validate forfeit team is one of the teams in the series
|
||||
if forfeitTeamID != *series.Team1ID && forfeitTeamID != *series.Team2ID {
|
||||
return BadRequest("forfeit team must be one of the teams in the series")
|
||||
}
|
||||
|
||||
// Determine winner and loser
|
||||
var winnerTeamID int
|
||||
if forfeitTeamID == *series.Team1ID {
|
||||
winnerTeamID = *series.Team2ID
|
||||
} else {
|
||||
winnerTeamID = *series.Team1ID
|
||||
}
|
||||
|
||||
// Discard all existing match results and fixtures
|
||||
for _, match := range series.Matches {
|
||||
if match.FixtureID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
result, err := GetFixtureResult(ctx, tx, *match.FixtureID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetFixtureResult")
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
// Delete result (CASCADE deletes player stats)
|
||||
err = DeleteByID[FixtureResult](tx, result.ID).
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "fixture_results.discard_for_forfeit",
|
||||
ResourceType: "fixture_result",
|
||||
ResourceID: result.ID,
|
||||
Details: map[string]any{
|
||||
"fixture_id": *match.FixtureID,
|
||||
"series_id": seriesID,
|
||||
},
|
||||
}).Delete(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "DeleteByID fixture_result")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the fixture
|
||||
err = DeleteByID[Fixture](tx, *match.FixtureID).
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "playoff_fixture.delete_for_forfeit",
|
||||
ResourceType: "fixture",
|
||||
ResourceID: *match.FixtureID,
|
||||
Details: map[string]any{
|
||||
"series_id": seriesID,
|
||||
},
|
||||
}).Delete(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "DeleteByID fixture")
|
||||
}
|
||||
|
||||
// Clear fixture ID from match
|
||||
match.FixtureID = nil
|
||||
match.Status = "pending"
|
||||
err = UpdateByID(tx, match.ID, match).
|
||||
Column("fixture_id", "status").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID playoff_match")
|
||||
}
|
||||
}
|
||||
|
||||
// Update series with forfeit info
|
||||
var reasonPtr *string
|
||||
if reason != "" {
|
||||
reasonPtr = &reason
|
||||
}
|
||||
|
||||
series.Status = SeriesStatusCompleted
|
||||
series.IsForfeit = true
|
||||
series.ForfeitTeamID = &forfeitTeamID
|
||||
series.ForfeitReason = reasonPtr
|
||||
series.WinnerTeamID = &winnerTeamID
|
||||
series.LoserTeamID = &forfeitTeamID
|
||||
|
||||
err = UpdateByID(tx, series.ID, series).
|
||||
Column("status", "is_forfeit", "forfeit_team_id", "forfeit_reason", "winner_team_id", "loser_team_id").
|
||||
WithAudit(audit, &AuditInfo{
|
||||
Action: "playoff_series.forfeit",
|
||||
ResourceType: "playoff_series",
|
||||
ResourceID: series.ID,
|
||||
Details: map[string]any{
|
||||
"forfeit_team_id": forfeitTeamID,
|
||||
"winner_team_id": winnerTeamID,
|
||||
"reason": reason,
|
||||
},
|
||||
}).Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "UpdateByID series forfeit")
|
||||
}
|
||||
|
||||
// Advance winner to next series
|
||||
if series.WinnerNextID != nil && series.WinnerNextSlot != nil {
|
||||
err = advanceTeamToSeries(ctx, tx, *series.WinnerNextID, *series.WinnerNextSlot, winnerTeamID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "advanceTeamToSeries winner")
|
||||
}
|
||||
}
|
||||
|
||||
// Advance loser to next series (e.g. lower bracket)
|
||||
if series.LoserNextID != nil && series.LoserNextSlot != nil {
|
||||
err = advanceTeamToSeries(ctx, tx, *series.LoserNextID, *series.LoserNextSlot, forfeitTeamID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "advanceTeamToSeries loser")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasPendingSeriesResults checks if a series has any pending (non-finalized) results.
|
||||
func HasPendingSeriesResults(ctx context.Context, tx bun.Tx, seriesID int) (bool, error) {
|
||||
series, err := GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
|
||||
@@ -267,9 +267,6 @@
|
||||
.top-0 {
|
||||
top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-1 {
|
||||
top: calc(var(--spacing) * 1);
|
||||
}
|
||||
.top-1\/2 {
|
||||
top: calc(1 / 2 * 100%);
|
||||
}
|
||||
@@ -342,9 +339,6 @@
|
||||
.-mt-3 {
|
||||
margin-top: calc(var(--spacing) * -3);
|
||||
}
|
||||
.mt-0 {
|
||||
margin-top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.mt-0\.5 {
|
||||
margin-top: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -378,9 +372,6 @@
|
||||
.mt-12 {
|
||||
margin-top: calc(var(--spacing) * 12);
|
||||
}
|
||||
.mt-16 {
|
||||
margin-top: calc(var(--spacing) * 16);
|
||||
}
|
||||
.mt-24 {
|
||||
margin-top: calc(var(--spacing) * 24);
|
||||
}
|
||||
@@ -658,22 +649,12 @@
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.-translate-y-1 {
|
||||
--tw-translate-y: calc(var(--spacing) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: calc(calc(1 / 2 * 100%) * -1);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
@@ -1293,9 +1274,6 @@
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-0 {
|
||||
padding-block: calc(var(--spacing) * 0);
|
||||
}
|
||||
.py-0\.5 {
|
||||
padding-block: calc(var(--spacing) * 0.5);
|
||||
}
|
||||
@@ -1588,10 +1566,6 @@
|
||||
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.outline {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.blur {
|
||||
--tw-blur: blur(8px);
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
@@ -2741,11 +2715,6 @@
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-blur {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -2848,7 +2817,6 @@
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
--tw-contrast: initial;
|
||||
|
||||
94
internal/handlers/series_forfeit.go
Normal file
94
internal/handlers/series_forfeit.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/oslstats/internal/db"
|
||||
"git.haelnorr.com/h/oslstats/internal/notify"
|
||||
"git.haelnorr.com/h/oslstats/internal/respond"
|
||||
"git.haelnorr.com/h/oslstats/internal/throw"
|
||||
"git.haelnorr.com/h/oslstats/internal/validation"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
// ForfeitSeries handles POST /series/{series_id}/forfeit
|
||||
// Forfeits a playoff series. The forfeiting team loses and the opponent wins
|
||||
// and advances through the bracket. Requires playoffs.manage permission.
|
||||
func ForfeitSeries(
|
||||
s *hws.Server,
|
||||
conn *db.DB,
|
||||
) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seriesID, err := strconv.Atoi(r.PathValue("series_id"))
|
||||
if err != nil {
|
||||
throw.BadRequest(s, w, r, "Invalid series ID", err)
|
||||
return
|
||||
}
|
||||
|
||||
getter, ok := validation.ParseFormOrNotify(s, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
forfeitTeamStr := getter.String("forfeit_team").TrimSpace().Required().Value
|
||||
forfeitReason := getter.String("forfeit_reason").TrimSpace().Value
|
||||
if !getter.ValidateAndNotify(s, w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate forfeit_team is "team1" or "team2"
|
||||
if forfeitTeamStr != "team1" && forfeitTeamStr != "team2" {
|
||||
notify.Warn(s, w, r, "Invalid Team", "Please select which team is forfeiting.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||
series, err := db.GetPlayoffSeriesByID(ctx, tx, seriesID)
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
respond.NotFound(w, errors.Wrap(err, "db.GetPlayoffSeriesByID"))
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.GetPlayoffSeriesByID")
|
||||
}
|
||||
if series == nil {
|
||||
respond.NotFound(w, errors.New("series not found"))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Resolve the forfeit team ID
|
||||
var forfeitTeamID int
|
||||
if forfeitTeamStr == "team1" {
|
||||
if series.Team1ID == nil {
|
||||
notify.Warn(s, w, r, "No Team", "Team 1 is not assigned.", nil)
|
||||
return false, nil
|
||||
}
|
||||
forfeitTeamID = *series.Team1ID
|
||||
} else {
|
||||
if series.Team2ID == nil {
|
||||
notify.Warn(s, w, r, "No Team", "Team 2 is not assigned.", nil)
|
||||
return false, nil
|
||||
}
|
||||
forfeitTeamID = *series.Team2ID
|
||||
}
|
||||
|
||||
err = db.ForfeitSeries(ctx, tx, seriesID, forfeitTeamID, forfeitReason, db.NewAuditFromRequest(r))
|
||||
if err != nil {
|
||||
if db.IsBadRequest(err) {
|
||||
notify.Warn(s, w, r, "Cannot Forfeit", err.Error(), nil)
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrap(err, "db.ForfeitSeries")
|
||||
}
|
||||
return true, nil
|
||||
}); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
notify.SuccessWithDelay(s, w, r, "Series Forfeited", "The series has been forfeited and the opponent advances.", nil)
|
||||
respond.HXRedirect(w, "/series/%d", seriesID)
|
||||
})
|
||||
}
|
||||
@@ -417,6 +417,12 @@ func addRoutes(
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.SeriesDiscardResults(s, conn)),
|
||||
},
|
||||
// Series forfeit route
|
||||
{
|
||||
Path: "/series/{series_id}/forfeit",
|
||||
Method: hws.MethodPOST,
|
||||
Handler: perms.RequirePermission(s, permissions.PlayoffsManage)(handlers.ForfeitSeries(s, conn)),
|
||||
},
|
||||
}
|
||||
|
||||
playerRoutes := []hws.Route{
|
||||
|
||||
@@ -201,6 +201,7 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
|
||||
{{
|
||||
hasTeams := series.Team1 != nil || series.Team2 != nil
|
||||
seriesURL := fmt.Sprintf("/series/%d", series.ID)
|
||||
isForfeit := series.IsForfeit
|
||||
}}
|
||||
<div
|
||||
data-series={ fmt.Sprint(series.SeriesNumber) }
|
||||
@@ -209,7 +210,8 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
|
||||
}
|
||||
class={ "bg-surface0 border rounded-lg overflow-hidden",
|
||||
templ.KV("border-blue/50", series.Status == db.SeriesStatusInProgress),
|
||||
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress),
|
||||
templ.KV("border-red/50", isForfeit),
|
||||
templ.KV("border-surface1", series.Status != db.SeriesStatusInProgress && !isForfeit),
|
||||
templ.KV("hover:bg-surface1 hover:cursor-pointer transition", hasTeams) }
|
||||
>
|
||||
<!-- Series Header -->
|
||||
@@ -218,17 +220,24 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
|
||||
<span class="text-xs font-semibold text-subtext0">{ series.Label }</span>
|
||||
@seriesFormatBadge(series.MatchesToWin)
|
||||
</div>
|
||||
@seriesStatusBadge(series.Status)
|
||||
<div class="flex items-center gap-1">
|
||||
if isForfeit {
|
||||
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-bold">
|
||||
FF
|
||||
</span>
|
||||
}
|
||||
@seriesStatusBadge(series.Status)
|
||||
</div>
|
||||
</div>
|
||||
<!-- Teams -->
|
||||
<div class="divide-y divide-surface1">
|
||||
@seriesTeamRow(season, league, series.Team1, series.Team1Seed, series.Team1Wins,
|
||||
series.WinnerTeamID, series.MatchesToWin)
|
||||
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
|
||||
@seriesTeamRow(season, league, series.Team2, series.Team2Seed, series.Team2Wins,
|
||||
series.WinnerTeamID, series.MatchesToWin)
|
||||
series.WinnerTeamID, series.ForfeitTeamID, series.MatchesToWin)
|
||||
</div>
|
||||
<!-- Series Score -->
|
||||
if series.MatchesToWin > 1 {
|
||||
if series.MatchesToWin > 1 && !isForfeit {
|
||||
<div class="bg-mantle px-3 py-1 text-center text-xs text-subtext0 border-t border-surface1">
|
||||
{ fmt.Sprint(series.Team1Wins) } - { fmt.Sprint(series.Team2Wins) }
|
||||
</div>
|
||||
@@ -236,16 +245,21 @@ templ seriesCard(season *db.Season, league *db.League, series *db.PlayoffSeries)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, matchesToWin int) {
|
||||
templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *int, wins int, winnerID *int, forfeitTeamID *int, matchesToWin int) {
|
||||
{{
|
||||
isWinner := false
|
||||
if team != nil && winnerID != nil {
|
||||
isWinner = team.ID == *winnerID
|
||||
}
|
||||
isForfeiter := false
|
||||
if team != nil && forfeitTeamID != nil {
|
||||
isForfeiter = team.ID == *forfeitTeamID
|
||||
}
|
||||
isTBD := team == nil
|
||||
}}
|
||||
<div class={ "flex items-center justify-between px-3 py-2",
|
||||
templ.KV("bg-green/5", isWinner) }>
|
||||
templ.KV("bg-green/5", isWinner && !isForfeiter),
|
||||
templ.KV("bg-red/5", isForfeiter) }>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
if seed != nil {
|
||||
<span class="text-xs font-mono text-subtext0 w-4 text-right flex-shrink-0">
|
||||
@@ -260,12 +274,14 @@ templ seriesTeamRow(season *db.Season, league *db.League, team *db.Team, seed *i
|
||||
<div class="truncate">
|
||||
@links.TeamLinkInSeason(team, season, league)
|
||||
</div>
|
||||
if isWinner {
|
||||
if isForfeiter {
|
||||
<span class="text-red text-xs font-bold flex-shrink-0">FF</span>
|
||||
} else if isWinner {
|
||||
<span class="text-green text-xs flex-shrink-0">✓</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if matchesToWin > 1 {
|
||||
if matchesToWin > 1 && forfeitTeamID == nil {
|
||||
<span class={ "text-sm font-mono flex-shrink-0 ml-2",
|
||||
templ.KV("text-text", !isWinner),
|
||||
templ.KV("text-green font-bold", isWinner) }>
|
||||
|
||||
@@ -312,30 +312,164 @@ templ seriesUploadPrompt(series *db.PlayoffSeries) {
|
||||
}
|
||||
}
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg p-6 text-center">
|
||||
<div
|
||||
x-data="{ open: false }"
|
||||
class="bg-mantle border border-surface1 rounded-lg p-6 text-center"
|
||||
>
|
||||
if hasPendingMatches {
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<p class="text-lg text-text font-medium mb-2">Results Pending Review</p>
|
||||
<p class="text-sm text-subtext1 mb-4">Uploaded results are waiting to be reviewed and finalized.</p>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
|
||||
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Review Results
|
||||
</a>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/review", series.ID)) }
|
||||
class="inline-block px-4 py-2 bg-green hover:bg-green/75 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Review Results
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@click="open = true"
|
||||
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Forfeit Series
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="text-4xl mb-3">📋</div>
|
||||
<p class="text-lg text-text font-medium mb-2">No Results Uploaded</p>
|
||||
<p class="text-sm text-subtext1 mb-4">Upload match log files to record the series results.</p>
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.ID)) }
|
||||
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Upload Match Logs
|
||||
</a>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<a
|
||||
href={ templ.SafeURL(fmt.Sprintf("/series/%d/results/upload", series.ID)) }
|
||||
class="inline-block px-4 py-2 bg-blue hover:bg-blue/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Upload Match Logs
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@click="open = true"
|
||||
class="inline-block px-4 py-2 bg-red hover:bg-red/80 text-mantle rounded-lg
|
||||
font-medium transition hover:cursor-pointer"
|
||||
>
|
||||
Forfeit Series
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@seriesForfeitModal(series)
|
||||
</div>
|
||||
}
|
||||
|
||||
templ seriesForfeitModal(series *db.PlayoffSeries) {
|
||||
{{
|
||||
team1Name := seriesTeamName(series.Team1)
|
||||
team2Name := seriesTeamName(series.Team2)
|
||||
}}
|
||||
<div
|
||||
x-show="open"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-base/75 transition-opacity"
|
||||
@click="open = false"
|
||||
></div>
|
||||
<!-- Modal panel -->
|
||||
<div class="flex min-h-full items-center justify-center p-4">
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="relative transform overflow-hidden rounded-lg bg-mantle border-2 border-surface1 shadow-xl transition-all sm:w-full sm:max-w-lg"
|
||||
@click.stop
|
||||
x-data="{ forfeitTeam: '', forfeitReason: '' }"
|
||||
>
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/series/%d/forfeit", series.ID) }
|
||||
hx-swap="none"
|
||||
>
|
||||
<div class="bg-mantle px-4 pb-4 pt-5 sm:p-6">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red/10 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="h-6 w-6 text-red" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left w-full">
|
||||
<h3 class="text-lg font-semibold leading-6 text-text">Forfeit Series</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-subtext0 mb-4">
|
||||
This will forfeit the entire series. The selected team will lose and the opponent
|
||||
will be declared the winner and advance. Any existing match results will be discarded.
|
||||
This action is immediate and cannot be undone.
|
||||
</p>
|
||||
<!-- Team Selection -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-text">Which team is forfeiting?</label>
|
||||
<select
|
||||
name="forfeit_team"
|
||||
x-model="forfeitTeam"
|
||||
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
|
||||
focus:border-red focus:outline-none hover:cursor-pointer"
|
||||
>
|
||||
<option value="">Select a team...</option>
|
||||
<option value="team1">{ team1Name }</option>
|
||||
<option value="team2">{ team2Name }</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Reason -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<label class="text-sm font-medium text-text">Reason (optional)</label>
|
||||
<textarea
|
||||
name="forfeit_reason"
|
||||
x-model="forfeitReason"
|
||||
placeholder="Provide a reason for the forfeit..."
|
||||
class="w-full px-3 py-2 bg-surface0 border border-surface1 rounded-lg text-text
|
||||
focus:border-blue focus:outline-none resize-none"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface0 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6 gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex w-full justify-center rounded-lg bg-red px-4 py-2 text-sm font-semibold text-mantle shadow-sm hover:bg-red/75 hover:cursor-pointer transition sm:w-auto"
|
||||
:disabled="forfeitTeam === ''"
|
||||
:class="forfeitTeam === '' && 'opacity-50 cursor-not-allowed'"
|
||||
>
|
||||
Confirm Forfeit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="open = false"
|
||||
class="mt-3 inline-flex w-full justify-center rounded-lg bg-surface1 px-4 py-2 text-sm font-semibold text-text shadow-sm hover:bg-surface2 hover:cursor-pointer transition sm:mt-0 sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -345,11 +479,21 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
||||
team1Won := series.WinnerTeamID != nil && series.Team1ID != nil && *series.WinnerTeamID == *series.Team1ID
|
||||
team2Won := series.WinnerTeamID != nil && series.Team2ID != nil && *series.WinnerTeamID == *series.Team2ID
|
||||
isBye := series.Status == db.SeriesStatusBye
|
||||
isForfeit := series.IsForfeit
|
||||
forfeitTeamName := ""
|
||||
if isForfeit && series.ForfeitTeam != nil {
|
||||
forfeitTeamName = series.ForfeitTeam.Name
|
||||
}
|
||||
}}
|
||||
<div class="bg-mantle border border-surface1 rounded-lg overflow-hidden">
|
||||
<div class="bg-surface0 border-b border-surface1 px-4 py-3 flex items-center justify-between">
|
||||
<h2 class="text-lg font-bold text-text">Series Score</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
if isForfeit {
|
||||
<span class="px-2 py-0.5 bg-red/20 text-red rounded text-xs font-medium">
|
||||
Forfeited
|
||||
</span>
|
||||
}
|
||||
@seriesStatusBadge(series.Status)
|
||||
@seriesFormatBadge(series.MatchesToWin)
|
||||
</div>
|
||||
@@ -386,7 +530,11 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
||||
</div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-4xl text-subtext0 font-light leading-none">–</span>
|
||||
if isCompleted {
|
||||
if isCompleted && isForfeit {
|
||||
<span class="px-1.5 py-0.5 bg-red/20 text-red rounded text-xs font-semibold mt-1">
|
||||
FORFEIT
|
||||
</span>
|
||||
} else if isCompleted {
|
||||
<span class="px-1.5 py-0.5 bg-green/20 text-green rounded text-xs font-semibold mt-1">
|
||||
FINAL
|
||||
</span>
|
||||
@@ -412,6 +560,18 @@ templ seriesScoreDisplay(series *db.PlayoffSeries) {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if isForfeit && forfeitTeamName != "" {
|
||||
<div class="text-center mt-2">
|
||||
<p class="text-sm text-red/80">
|
||||
{ forfeitTeamName } forfeited the series
|
||||
</p>
|
||||
if series.ForfeitReason != nil && *series.ForfeitReason != "" {
|
||||
<p class="text-xs text-subtext0 mt-1">
|
||||
{ *series.ForfeitReason }
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user