package user import ( "context" "errors" "fmt" "strings" "time" "github.com/google/uuid" ) // ActiveLimit is the read-side projection of a row in `limit_active` // joined with the audit columns from the underlying `limit_records` // row. It mirrors the OpenAPI `ActiveLimit` schema. type ActiveLimit struct { LimitCode string Value int32 ReasonCode string Actor ActorRef AppliedAt time.Time ExpiresAt *time.Time } // ApplyLimitInput carries the admin-supplied parameters of // `POST /api/v1/admin/users/{user_id}/limits`. type ApplyLimitInput struct { UserID uuid.UUID LimitCode string Value int32 ReasonCode string Actor ActorRef ExpiresAt *time.Time } // ApplyLimit persists a fresh `limit_records` row and upserts // `limit_active` in one transaction. The implementation keeps `limit_code` as an // open string; The implementation may add a CHECK constraint once the closed // set is locked in. func (s *Service) ApplyLimit(ctx context.Context, input ApplyLimitInput) (Account, error) { if input.UserID == uuid.Nil { return Account{}, ErrAccountNotFound } if strings.TrimSpace(input.LimitCode) == "" { return Account{}, fmt.Errorf("%w: limit_code must be non-empty", ErrInvalidInput) } if err := input.Actor.Validate(); err != nil { return Account{}, err } if strings.TrimSpace(input.ReasonCode) == "" { return Account{}, fmt.Errorf("%w: reason_code must be non-empty", ErrInvalidInput) } now := s.deps.Now().UTC() expiresAt := input.ExpiresAt if expiresAt != nil { t := expiresAt.UTC() expiresAt = &t } if err := s.deps.Store.ApplyLimitTx(ctx, limitInsert{ UserID: input.UserID, LimitCode: input.LimitCode, Value: input.Value, ReasonCode: input.ReasonCode, Actor: input.Actor, AppliedAt: now, ExpiresAt: expiresAt, }); err != nil { if errors.Is(err, ErrAccountNotFound) { return Account{}, err } return Account{}, fmt.Errorf("user apply limit: %w", err) } return s.GetAccount(ctx, input.UserID) }