Replace product-kind branching and direct position writes with enclosed database functions driven by structural product shape. Migrate grant and provision data, unify operator issuance, update subscription and expiry flows, and add migration and integration proofs.
120 lines
4.4 KiB
Go
120 lines
4.4 KiB
Go
package entitlements
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Activities holds dependencies for entitlement-lifecycle workflows.
|
|
type Activities struct {
|
|
DB *sql.DB
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// NewActivities constructs an Activities ready for registration.
|
|
func NewActivities(db *sql.DB, logger *slog.Logger) *Activities {
|
|
return &Activities{DB: db, Logger: logger}
|
|
}
|
|
|
|
// ExpireGrantInput identifies the grant whose pools should transition off it.
|
|
type ExpireGrantInput struct {
|
|
GrantID string
|
|
}
|
|
|
|
// ExpireGrantOutput reports what the activity did. TransitionsRecorded is the
|
|
// number of positions ended by EndConferral for this grant source; Skipped is
|
|
// true when the grant was no longer active (already revoked/expired or absent),
|
|
// indicating the activity was a no-op.
|
|
type ExpireGrantOutput struct {
|
|
TransitionsRecorded int
|
|
Skipped bool
|
|
}
|
|
|
|
// ExpireGrantActivity decrees the grant expired (status active -> expired),
|
|
// ends every position conferred by this grant source via EndConferral, then
|
|
// re-materializes the org's default pool so entitlements settle to the default
|
|
// (or detach when no default is configured).
|
|
//
|
|
// The activity is branch-free and idempotent: ExpireGrant returns
|
|
// sql.ErrNoRows when the grant is already non-active (Skipped=true, a no-op),
|
|
// and EndConferral treats a source that resolves to nothing live as success
|
|
// (empty slice), so re-running against an already-ended grant records zero
|
|
// transitions rather than erroring.
|
|
func (a *Activities) ExpireGrantActivity(ctx context.Context, input ExpireGrantInput) (ExpireGrantOutput, error) {
|
|
if input.GrantID == "" {
|
|
return ExpireGrantOutput{}, fmt.Errorf("grant_id is required")
|
|
}
|
|
|
|
tx, err := a.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
q := entitlements.New(tx)
|
|
|
|
// Decree: flip status active -> expired. sql.ErrNoRows means the grant was
|
|
// not active (already revoked/expired or absent) — a no-op.
|
|
grant, err := q.ExpireGrant(ctx, input.GrantID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
a.Logger.Info("ExpireGrantActivity: grant not active, skipping",
|
|
slog.String("grant_id", input.GrantID))
|
|
return ExpireGrantOutput{Skipped: true}, nil
|
|
}
|
|
return ExpireGrantOutput{}, fmt.Errorf("expire grant %s: %w", input.GrantID, err)
|
|
}
|
|
|
|
// Resolve the org's default pool for re-materialization. A grant with no
|
|
// granted-to org has no pool to settle, so skip materialization for it.
|
|
var pool entitlements.ResourcePool
|
|
havePool := false
|
|
if grant.GrantedToOrgID.Valid {
|
|
pool, err = q.GetDefaultPoolByOrgID(ctx, grant.GrantedToOrgID.UUID.String())
|
|
if err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("default pool for org %s: %w", grant.GrantedToOrgID.UUID, err)
|
|
}
|
|
havePool = true
|
|
}
|
|
|
|
// End every position conferred by this grant source. Nothing live resolves
|
|
// to an empty slice (success), keeping the activity idempotent on re-run.
|
|
ended, err := q.EndConferral(ctx, entitlements.EndConferralParams{
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(input.GrantID), Valid: true},
|
|
ActorType: "system",
|
|
Reason: sql.NullString{String: fmt.Sprintf("grant-expiration:%s", input.GrantID), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("end conferral for grant %s: %w", input.GrantID, err)
|
|
}
|
|
|
|
if havePool {
|
|
// Restore the org-type baseline when expiry left the default ladder
|
|
// vacant (guarded: a position held by another source is never
|
|
// superseded by the restoration).
|
|
if _, err := entitlements.ReapplyDefaultsIfVacant(ctx, tx, pool.PoolID, entitlements.Actor{
|
|
ActorType: "system",
|
|
Reason: fmt.Sprintf("grant-expiration:%s default restoration", input.GrantID),
|
|
}); err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("restore default for pool %s: %w", pool.PoolID, err)
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(ctx, q, pool.PoolID); err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("materialize pool %s: %w", pool.PoolID, err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return ExpireGrantOutput{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
|
|
// Skipped stays false: the grant was active when this activity ran. A
|
|
// zero count is a legitimate idempotent re-run against nothing live.
|
|
return ExpireGrantOutput{TransitionsRecorded: len(ended)}, nil
|
|
}
|