371 lines
11 KiB
Go
371 lines
11 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/pkg/errors"
|
|
"github.com/uptrace/bun"
|
|
)
|
|
|
|
// PlayoffSeriesSchedule represents a schedule proposal for a playoff series.
|
|
// Mirrors FixtureSchedule but references a series instead of a fixture.
|
|
type PlayoffSeriesSchedule struct {
|
|
bun.BaseModel `bun:"table:playoff_series_schedules,alias:pss"`
|
|
|
|
ID int `bun:"id,pk,autoincrement"`
|
|
SeriesID int `bun:",notnull"`
|
|
ScheduledTime *time.Time `bun:"scheduled_time"`
|
|
ProposedByTeamID int `bun:",notnull"`
|
|
AcceptedByTeamID *int `bun:"accepted_by_team_id"`
|
|
Status ScheduleStatus `bun:",notnull,default:'pending'"`
|
|
RescheduleReason *string `bun:"reschedule_reason"`
|
|
CreatedAt int64 `bun:",notnull"`
|
|
UpdatedAt *int64 `bun:"updated_at"`
|
|
|
|
Series *PlayoffSeries `bun:"rel:belongs-to,join:series_id=id"`
|
|
ProposedBy *Team `bun:"rel:belongs-to,join:proposed_by_team_id=id"`
|
|
AcceptedBy *Team `bun:"rel:belongs-to,join:accepted_by_team_id=id"`
|
|
}
|
|
|
|
// GetCurrentSeriesSchedule returns the most recent schedule record for a series.
|
|
// Returns nil, nil if no schedule exists.
|
|
func GetCurrentSeriesSchedule(ctx context.Context, tx bun.Tx, seriesID int) (*PlayoffSeriesSchedule, error) {
|
|
schedule := new(PlayoffSeriesSchedule)
|
|
err := tx.NewSelect().
|
|
Model(schedule).
|
|
Where("series_id = ?", seriesID).
|
|
Order("created_at DESC", "id DESC").
|
|
Relation("ProposedBy").
|
|
Relation("AcceptedBy").
|
|
Limit(1).
|
|
Scan(ctx)
|
|
if err != nil {
|
|
if err.Error() == "sql: no rows in result set" {
|
|
return nil, nil
|
|
}
|
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
|
}
|
|
return schedule, nil
|
|
}
|
|
|
|
// GetSeriesScheduleHistory returns all schedule records for a series in chronological order
|
|
func GetSeriesScheduleHistory(ctx context.Context, tx bun.Tx, seriesID int) ([]*PlayoffSeriesSchedule, error) {
|
|
schedules, err := GetList[PlayoffSeriesSchedule](tx).
|
|
Where("series_id = ?", seriesID).
|
|
Order("created_at ASC", "id ASC").
|
|
Relation("ProposedBy").
|
|
Relation("AcceptedBy").
|
|
GetAll(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetList")
|
|
}
|
|
return schedules, nil
|
|
}
|
|
|
|
// ProposeSeriesSchedule creates a new pending schedule proposal for a series.
|
|
// Cannot propose on cancelled or accepted schedules.
|
|
func ProposeSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
seriesID, proposedByTeamID int,
|
|
scheduledTime time.Time,
|
|
audit *AuditMeta,
|
|
) (*PlayoffSeriesSchedule, error) {
|
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
}
|
|
if current != nil {
|
|
switch current.Status {
|
|
case ScheduleStatusCancelled:
|
|
return nil, BadRequest("cannot propose a new time for a cancelled series")
|
|
case ScheduleStatusAccepted:
|
|
return nil, BadRequest("series already has an accepted schedule; use reschedule instead")
|
|
case ScheduleStatusPending:
|
|
// Supersede existing pending record
|
|
now := time.Now().Unix()
|
|
current.Status = ScheduleStatusRescheduled
|
|
current.UpdatedAt = &now
|
|
err = UpdateByID(tx, current.ID, current).
|
|
Column("status", "updated_at").
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "UpdateByID")
|
|
}
|
|
// rejected, rescheduled, postponed, withdrawn are terminal — safe to create a new proposal
|
|
}
|
|
}
|
|
|
|
schedule := &PlayoffSeriesSchedule{
|
|
SeriesID: seriesID,
|
|
ScheduledTime: &scheduledTime,
|
|
ProposedByTeamID: proposedByTeamID,
|
|
Status: ScheduleStatusPending,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.propose",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: seriesID,
|
|
Details: map[string]any{
|
|
"series_id": seriesID,
|
|
"proposed_by": proposedByTeamID,
|
|
"scheduled_time": scheduledTime,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "Insert")
|
|
}
|
|
return schedule, nil
|
|
}
|
|
|
|
// AcceptSeriesSchedule accepts a pending schedule proposal.
|
|
// The acceptedByTeamID must be the other team (not the proposer).
|
|
func AcceptSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
scheduleID, acceptedByTeamID int,
|
|
audit *AuditMeta,
|
|
) error {
|
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetByID")
|
|
}
|
|
if schedule.Status != ScheduleStatusPending {
|
|
return BadRequest("schedule is not in pending status")
|
|
}
|
|
if schedule.ProposedByTeamID == acceptedByTeamID {
|
|
return BadRequest("cannot accept your own proposal")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
schedule.AcceptedByTeamID = &acceptedByTeamID
|
|
schedule.Status = ScheduleStatusAccepted
|
|
schedule.UpdatedAt = &now
|
|
err = UpdateByID(tx, schedule.ID, schedule).
|
|
Column("accepted_by_team_id", "status", "updated_at").
|
|
WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.accept",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: scheduleID,
|
|
Details: map[string]any{
|
|
"series_id": schedule.SeriesID,
|
|
"accepted_by": acceptedByTeamID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RejectSeriesSchedule rejects a pending schedule proposal.
|
|
func RejectSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
scheduleID int,
|
|
audit *AuditMeta,
|
|
) error {
|
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetByID")
|
|
}
|
|
if schedule.Status != ScheduleStatusPending {
|
|
return BadRequest("schedule is not in pending status")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
schedule.Status = ScheduleStatusRejected
|
|
schedule.UpdatedAt = &now
|
|
err = UpdateByID(tx, schedule.ID, schedule).
|
|
Column("status", "updated_at").
|
|
WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.reject",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: scheduleID,
|
|
Details: map[string]any{
|
|
"series_id": schedule.SeriesID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RescheduleSeriesSchedule marks the current accepted schedule as rescheduled
|
|
// and creates a new pending proposal with the new time.
|
|
func RescheduleSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
seriesID, proposedByTeamID int,
|
|
newTime time.Time,
|
|
reason string,
|
|
audit *AuditMeta,
|
|
) (*PlayoffSeriesSchedule, error) {
|
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
}
|
|
if current == nil || current.Status != ScheduleStatusAccepted {
|
|
return nil, BadRequest("no accepted schedule to reschedule")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
current.Status = ScheduleStatusRescheduled
|
|
current.RescheduleReason = &reason
|
|
current.UpdatedAt = &now
|
|
err = UpdateByID(tx, current.ID, current).
|
|
Column("status", "reschedule_reason", "updated_at").
|
|
Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "UpdateByID")
|
|
}
|
|
|
|
// Create new pending proposal
|
|
schedule := &PlayoffSeriesSchedule{
|
|
SeriesID: seriesID,
|
|
ScheduledTime: &newTime,
|
|
ProposedByTeamID: proposedByTeamID,
|
|
Status: ScheduleStatusPending,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
err = Insert(tx, schedule).WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.reschedule",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: seriesID,
|
|
Details: map[string]any{
|
|
"series_id": seriesID,
|
|
"proposed_by": proposedByTeamID,
|
|
"new_time": newTime,
|
|
"reason": reason,
|
|
"old_schedule_id": current.ID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "Insert")
|
|
}
|
|
return schedule, nil
|
|
}
|
|
|
|
// PostponeSeriesSchedule marks the current accepted schedule as postponed.
|
|
// This is a terminal state — a new proposal can be created afterwards.
|
|
func PostponeSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
seriesID int,
|
|
reason string,
|
|
audit *AuditMeta,
|
|
) error {
|
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
}
|
|
if current == nil || current.Status != ScheduleStatusAccepted {
|
|
return BadRequest("no accepted schedule to postpone")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
current.Status = ScheduleStatusPostponed
|
|
current.RescheduleReason = &reason
|
|
current.UpdatedAt = &now
|
|
err = UpdateByID(tx, current.ID, current).
|
|
Column("status", "reschedule_reason", "updated_at").
|
|
WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.postpone",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: seriesID,
|
|
Details: map[string]any{
|
|
"series_id": seriesID,
|
|
"reason": reason,
|
|
"old_schedule_id": current.ID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WithdrawSeriesSchedule allows the proposer to withdraw their pending proposal.
|
|
// Only the team that proposed can withdraw it.
|
|
func WithdrawSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
scheduleID, withdrawByTeamID int,
|
|
audit *AuditMeta,
|
|
) error {
|
|
schedule, err := GetByID[PlayoffSeriesSchedule](tx, scheduleID).Get(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetByID")
|
|
}
|
|
if schedule.Status != ScheduleStatusPending {
|
|
return BadRequest("schedule is not in pending status")
|
|
}
|
|
if schedule.ProposedByTeamID != withdrawByTeamID {
|
|
return BadRequest("only the proposing team can withdraw their proposal")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
schedule.Status = ScheduleStatusWithdrawn
|
|
schedule.UpdatedAt = &now
|
|
err = UpdateByID(tx, schedule.ID, schedule).
|
|
Column("status", "updated_at").
|
|
WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.withdraw",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: scheduleID,
|
|
Details: map[string]any{
|
|
"series_id": schedule.SeriesID,
|
|
"withdrawn_by": withdrawByTeamID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CancelSeriesSchedule marks the current schedule as cancelled. This is a terminal state.
|
|
// Requires playoffs.manage permission (moderator-level).
|
|
func CancelSeriesSchedule(
|
|
ctx context.Context,
|
|
tx bun.Tx,
|
|
seriesID int,
|
|
reason string,
|
|
audit *AuditMeta,
|
|
) error {
|
|
current, err := GetCurrentSeriesSchedule(ctx, tx, seriesID)
|
|
if err != nil {
|
|
return errors.Wrap(err, "GetCurrentSeriesSchedule")
|
|
}
|
|
if current == nil {
|
|
return BadRequest("no schedule to cancel")
|
|
}
|
|
if current.Status.IsTerminal() {
|
|
return BadRequest("schedule is already in a terminal state")
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
current.Status = ScheduleStatusCancelled
|
|
current.RescheduleReason = &reason
|
|
current.UpdatedAt = &now
|
|
err = UpdateByID(tx, current.ID, current).
|
|
Column("status", "reschedule_reason", "updated_at").
|
|
WithAudit(audit, &AuditInfo{
|
|
Action: "series_schedule.cancel",
|
|
ResourceType: "playoff_series_schedule",
|
|
ResourceID: seriesID,
|
|
Details: map[string]any{
|
|
"series_id": seriesID,
|
|
"reason": reason,
|
|
"schedule_id": current.ID,
|
|
},
|
|
}).Exec(ctx)
|
|
if err != nil {
|
|
return errors.Wrap(err, "UpdateByID")
|
|
}
|
|
return nil
|
|
}
|