753 lines
28 KiB
Go
753 lines
28 KiB
Go
package userstore
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"galaxy/user/internal/domain/common"
|
|
"galaxy/user/internal/domain/entitlement"
|
|
"galaxy/user/internal/ports"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
type entitlementPeriodRecord struct {
|
|
RecordID string `json:"record_id"`
|
|
UserID string `json:"user_id"`
|
|
PlanCode string `json:"plan_code"`
|
|
Source string `json:"source"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID *string `json:"actor_id,omitempty"`
|
|
ReasonCode string `json:"reason_code"`
|
|
StartsAt string `json:"starts_at"`
|
|
EndsAt *string `json:"ends_at,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
ClosedAt *string `json:"closed_at,omitempty"`
|
|
ClosedByType *string `json:"closed_by_type,omitempty"`
|
|
ClosedByID *string `json:"closed_by_id,omitempty"`
|
|
ClosedReasonCode *string `json:"closed_reason_code,omitempty"`
|
|
}
|
|
|
|
// CreateEntitlementRecord stores one new entitlement history record.
|
|
func (store *Store) CreateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("create entitlement record in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalEntitlementPeriodRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("create entitlement record in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
|
historyKey := store.keyspace.EntitlementHistory(record.UserID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "create entitlement record in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if err := ensureKeyAbsent(operationCtx, tx, recordKey); err != nil {
|
|
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(record.StartsAt.UTC().UnixMicro()),
|
|
Member: record.RecordID.String(),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey, historyKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("create entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GetEntitlementRecordByRecordID returns the entitlement history record
|
|
// identified by recordID.
|
|
func (store *Store) GetEntitlementRecordByRecordID(
|
|
ctx context.Context,
|
|
recordID entitlement.EntitlementRecordID,
|
|
) (entitlement.PeriodRecord, error) {
|
|
if err := recordID.Validate(); err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "get entitlement record by record id from redis")
|
|
if err != nil {
|
|
return entitlement.PeriodRecord{}, err
|
|
}
|
|
defer cancel()
|
|
|
|
record, err := store.loadEntitlementRecord(operationCtx, store.client, recordID)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ports.ErrNotFound):
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, ports.ErrNotFound)
|
|
default:
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("get entitlement record by record id %q from redis: %w", recordID, err)
|
|
}
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
// ListEntitlementRecordsByUserID returns every entitlement history record
|
|
// owned by userID.
|
|
func (store *Store) ListEntitlementRecordsByUserID(
|
|
ctx context.Context,
|
|
userID common.UserID,
|
|
) ([]entitlement.PeriodRecord, error) {
|
|
if err := userID.Validate(); err != nil {
|
|
return nil, fmt.Errorf("list entitlement records by user id from redis: %w", err)
|
|
}
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "list entitlement records by user id from redis")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer cancel()
|
|
|
|
recordIDs, err := store.client.ZRange(operationCtx, store.keyspace.EntitlementHistory(userID), 0, -1).Result()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
|
}
|
|
|
|
records := make([]entitlement.PeriodRecord, 0, len(recordIDs))
|
|
for _, rawRecordID := range recordIDs {
|
|
record, err := store.loadEntitlementRecord(operationCtx, store.client, entitlement.EntitlementRecordID(rawRecordID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list entitlement records by user id %q from redis: %w", userID, err)
|
|
}
|
|
records = append(records, record)
|
|
}
|
|
|
|
return records, nil
|
|
}
|
|
|
|
// UpdateEntitlementRecord replaces one stored entitlement history record.
|
|
func (store *Store) UpdateEntitlementRecord(ctx context.Context, record entitlement.PeriodRecord) error {
|
|
if err := record.Validate(); err != nil {
|
|
return fmt.Errorf("update entitlement record in redis: %w", err)
|
|
}
|
|
|
|
payload, err := marshalEntitlementPeriodRecord(record)
|
|
if err != nil {
|
|
return fmt.Errorf("update entitlement record in redis: %w", err)
|
|
}
|
|
|
|
recordKey := store.keyspace.EntitlementRecord(record.RecordID)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "update entitlement record in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
if _, err := store.loadEntitlementRecord(operationCtx, tx, record.RecordID); err != nil {
|
|
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
_, err := tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, recordKey, payload, 0)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, err)
|
|
}
|
|
|
|
return nil
|
|
}, recordKey)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("update entitlement record %q in redis: %w", record.RecordID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// GrantEntitlement atomically closes the current free history record, creates
|
|
// one paid history record, and replaces the current snapshot.
|
|
func (store *Store) GrantEntitlement(ctx context.Context, input ports.GrantEntitlementInput) error {
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("grant entitlement in redis: %w", err)
|
|
}
|
|
|
|
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement in redis: %w", err)
|
|
}
|
|
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement in redis: %w", err)
|
|
}
|
|
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement in redis: %w", err)
|
|
}
|
|
|
|
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
|
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
|
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
|
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
|
watchedKeys := append(
|
|
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
|
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
|
)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "grant entitlement in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
|
|
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
|
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
|
Member: input.NewRecord.RecordID.String(),
|
|
})
|
|
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
|
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
|
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, watchedKeys...)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("grant entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ExtendEntitlement atomically appends one paid history segment and replaces
|
|
// the current paid snapshot.
|
|
func (store *Store) ExtendEntitlement(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("extend entitlement in redis: %w", err)
|
|
}
|
|
|
|
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("extend entitlement in redis: %w", err)
|
|
}
|
|
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("extend entitlement in redis: %w", err)
|
|
}
|
|
|
|
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
|
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
|
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
|
watchedKeys := append(
|
|
[]string{newRecordKey, historyKey, snapshotKey},
|
|
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
|
)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "extend entitlement in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
|
Member: input.NewRecord.RecordID.String(),
|
|
})
|
|
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
|
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
|
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, watchedKeys...)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("extend entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// RevokeEntitlement atomically closes the current paid history record,
|
|
// creates one free history record, and replaces the current snapshot.
|
|
func (store *Store) RevokeEntitlement(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
|
}
|
|
|
|
updatedCurrentRecordPayload, err := marshalEntitlementPeriodRecord(input.UpdatedCurrentRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
|
}
|
|
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
|
}
|
|
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement in redis: %w", err)
|
|
}
|
|
|
|
currentRecordKey := store.keyspace.EntitlementRecord(input.ExpectedCurrentRecord.RecordID)
|
|
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
|
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
|
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
|
watchedKeys := append(
|
|
[]string{currentRecordKey, newRecordKey, historyKey, snapshotKey},
|
|
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
|
)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "revoke entitlement in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedCurrentSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedCurrentSnapshot) {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
|
|
storedCurrentRecord, err := store.loadEntitlementRecord(operationCtx, tx, input.ExpectedCurrentRecord.RecordID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementPeriodRecords(storedCurrentRecord, input.ExpectedCurrentRecord) {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, currentRecordKey, updatedCurrentRecordPayload, 0)
|
|
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
|
Member: input.NewRecord.RecordID.String(),
|
|
})
|
|
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
|
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
|
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, watchedKeys...)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("revoke entitlement for user %q in redis: %w", input.ExpectedCurrentSnapshot.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// RepairExpiredEntitlement atomically replaces one expired finite paid
|
|
// snapshot with a materialized free state.
|
|
func (store *Store) RepairExpiredEntitlement(ctx context.Context, input ports.RepairExpiredEntitlementInput) error {
|
|
if err := input.Validate(); err != nil {
|
|
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
|
}
|
|
|
|
newRecordPayload, err := marshalEntitlementPeriodRecord(input.NewRecord)
|
|
if err != nil {
|
|
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
|
}
|
|
newSnapshotPayload, err := marshalEntitlementSnapshotRecord(input.NewSnapshot)
|
|
if err != nil {
|
|
return fmt.Errorf("repair expired entitlement in redis: %w", err)
|
|
}
|
|
|
|
newRecordKey := store.keyspace.EntitlementRecord(input.NewRecord.RecordID)
|
|
historyKey := store.keyspace.EntitlementHistory(input.NewRecord.UserID)
|
|
snapshotKey := store.keyspace.EntitlementSnapshot(input.NewSnapshot.UserID)
|
|
watchedKeys := append(
|
|
[]string{newRecordKey, historyKey, snapshotKey},
|
|
store.activeSanctionWatchKeys(input.NewSnapshot.UserID)...,
|
|
)
|
|
|
|
operationCtx, cancel, err := store.operationContext(ctx, "repair expired entitlement in redis")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cancel()
|
|
|
|
watchErr := store.client.Watch(operationCtx, func(tx *redis.Tx) error {
|
|
storedSnapshot, err := store.loadEntitlementSnapshot(operationCtx, tx, input.ExpectedExpiredSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
|
}
|
|
if !equalEntitlementSnapshots(storedSnapshot, input.ExpectedExpiredSnapshot) {
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
|
}
|
|
if err := ensureKeyAbsent(operationCtx, tx, newRecordKey); err != nil {
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
|
}
|
|
activeSanctionCodes, err := store.loadActiveSanctionCodeSet(operationCtx, tx, input.NewSnapshot.UserID)
|
|
if err != nil {
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
|
}
|
|
|
|
_, err = tx.TxPipelined(operationCtx, func(pipe redis.Pipeliner) error {
|
|
pipe.Set(operationCtx, newRecordKey, newRecordPayload, 0)
|
|
pipe.ZAdd(operationCtx, historyKey, redis.Z{
|
|
Score: float64(input.NewRecord.StartsAt.UTC().UnixMicro()),
|
|
Member: input.NewRecord.RecordID.String(),
|
|
})
|
|
pipe.Set(operationCtx, snapshotKey, newSnapshotPayload, 0)
|
|
store.syncEntitlementIndexes(pipe, operationCtx, input.NewSnapshot)
|
|
store.syncEligibilityMarkerIndexes(pipe, operationCtx, input.NewSnapshot.UserID, input.NewSnapshot.IsPaid, activeSanctionCodes)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, err)
|
|
}
|
|
|
|
return nil
|
|
}, watchedKeys...)
|
|
|
|
switch {
|
|
case errors.Is(watchErr, redis.TxFailedErr):
|
|
return fmt.Errorf("repair expired entitlement for user %q in redis: %w", input.ExpectedExpiredSnapshot.UserID, ports.ErrConflict)
|
|
case watchErr != nil:
|
|
return watchErr
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (store *Store) loadEntitlementRecord(
|
|
ctx context.Context,
|
|
getter bytesGetter,
|
|
recordID entitlement.EntitlementRecordID,
|
|
) (entitlement.PeriodRecord, error) {
|
|
payload, err := getter.Get(ctx, store.keyspace.EntitlementRecord(recordID)).Bytes()
|
|
switch {
|
|
case errors.Is(err, redis.Nil):
|
|
return entitlement.PeriodRecord{}, ports.ErrNotFound
|
|
case err != nil:
|
|
return entitlement.PeriodRecord{}, err
|
|
}
|
|
|
|
return decodeEntitlementPeriodRecord(payload)
|
|
}
|
|
|
|
func marshalEntitlementPeriodRecord(record entitlement.PeriodRecord) ([]byte, error) {
|
|
encoded := entitlementPeriodRecord{
|
|
RecordID: record.RecordID.String(),
|
|
UserID: record.UserID.String(),
|
|
PlanCode: string(record.PlanCode),
|
|
Source: record.Source.String(),
|
|
ActorType: record.Actor.Type.String(),
|
|
ReasonCode: record.ReasonCode.String(),
|
|
StartsAt: record.StartsAt.UTC().Format(time.RFC3339Nano),
|
|
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
}
|
|
if !record.Actor.ID.IsZero() {
|
|
value := record.Actor.ID.String()
|
|
encoded.ActorID = &value
|
|
}
|
|
if record.EndsAt != nil {
|
|
value := record.EndsAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.EndsAt = &value
|
|
}
|
|
if record.ClosedAt != nil {
|
|
value := record.ClosedAt.UTC().Format(time.RFC3339Nano)
|
|
encoded.ClosedAt = &value
|
|
}
|
|
if !record.ClosedBy.Type.IsZero() {
|
|
value := record.ClosedBy.Type.String()
|
|
encoded.ClosedByType = &value
|
|
}
|
|
if !record.ClosedBy.ID.IsZero() {
|
|
value := record.ClosedBy.ID.String()
|
|
encoded.ClosedByID = &value
|
|
}
|
|
if !record.ClosedReasonCode.IsZero() {
|
|
value := record.ClosedReasonCode.String()
|
|
encoded.ClosedReasonCode = &value
|
|
}
|
|
|
|
return json.Marshal(encoded)
|
|
}
|
|
|
|
func decodeEntitlementPeriodRecord(payload []byte) (entitlement.PeriodRecord, error) {
|
|
var encoded entitlementPeriodRecord
|
|
if err := decodeJSONPayload(payload, &encoded); err != nil {
|
|
return entitlement.PeriodRecord{}, err
|
|
}
|
|
|
|
startsAt, err := time.Parse(time.RFC3339Nano, encoded.StartsAt)
|
|
if err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record starts_at: %w", err)
|
|
}
|
|
createdAt, err := time.Parse(time.RFC3339Nano, encoded.CreatedAt)
|
|
if err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record created_at: %w", err)
|
|
}
|
|
|
|
record := entitlement.PeriodRecord{
|
|
RecordID: entitlement.EntitlementRecordID(encoded.RecordID),
|
|
UserID: common.UserID(encoded.UserID),
|
|
PlanCode: entitlement.PlanCode(encoded.PlanCode),
|
|
Source: common.Source(encoded.Source),
|
|
Actor: common.ActorRef{Type: common.ActorType(encoded.ActorType)},
|
|
ReasonCode: common.ReasonCode(encoded.ReasonCode),
|
|
StartsAt: startsAt.UTC(),
|
|
CreatedAt: createdAt.UTC(),
|
|
}
|
|
if encoded.ActorID != nil {
|
|
record.Actor.ID = common.ActorID(*encoded.ActorID)
|
|
}
|
|
if encoded.EndsAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.EndsAt)
|
|
if err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record ends_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.EndsAt = &value
|
|
}
|
|
if encoded.ClosedAt != nil {
|
|
value, err := time.Parse(time.RFC3339Nano, *encoded.ClosedAt)
|
|
if err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record closed_at: %w", err)
|
|
}
|
|
value = value.UTC()
|
|
record.ClosedAt = &value
|
|
}
|
|
if encoded.ClosedByType != nil {
|
|
record.ClosedBy.Type = common.ActorType(*encoded.ClosedByType)
|
|
}
|
|
if encoded.ClosedByID != nil {
|
|
record.ClosedBy.ID = common.ActorID(*encoded.ClosedByID)
|
|
}
|
|
if encoded.ClosedReasonCode != nil {
|
|
record.ClosedReasonCode = common.ReasonCode(*encoded.ClosedReasonCode)
|
|
}
|
|
if err := record.Validate(); err != nil {
|
|
return entitlement.PeriodRecord{}, fmt.Errorf("decode entitlement period record: %w", err)
|
|
}
|
|
|
|
return record, nil
|
|
}
|
|
|
|
func equalEntitlementSnapshots(left entitlement.CurrentSnapshot, right entitlement.CurrentSnapshot) bool {
|
|
return left.UserID == right.UserID &&
|
|
left.PlanCode == right.PlanCode &&
|
|
left.IsPaid == right.IsPaid &&
|
|
left.StartsAt.Equal(right.StartsAt) &&
|
|
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
|
left.Source == right.Source &&
|
|
left.Actor == right.Actor &&
|
|
left.ReasonCode == right.ReasonCode &&
|
|
left.UpdatedAt.Equal(right.UpdatedAt)
|
|
}
|
|
|
|
func equalEntitlementPeriodRecords(left entitlement.PeriodRecord, right entitlement.PeriodRecord) bool {
|
|
return left.RecordID == right.RecordID &&
|
|
left.UserID == right.UserID &&
|
|
left.PlanCode == right.PlanCode &&
|
|
left.Source == right.Source &&
|
|
left.Actor == right.Actor &&
|
|
left.ReasonCode == right.ReasonCode &&
|
|
left.StartsAt.Equal(right.StartsAt) &&
|
|
equalOptionalTime(left.EndsAt, right.EndsAt) &&
|
|
left.CreatedAt.Equal(right.CreatedAt) &&
|
|
equalOptionalTime(left.ClosedAt, right.ClosedAt) &&
|
|
left.ClosedBy == right.ClosedBy &&
|
|
left.ClosedReasonCode == right.ClosedReasonCode
|
|
}
|
|
|
|
func equalOptionalTime(left *time.Time, right *time.Time) bool {
|
|
switch {
|
|
case left == nil && right == nil:
|
|
return true
|
|
case left == nil || right == nil:
|
|
return false
|
|
default:
|
|
return left.Equal(*right)
|
|
}
|
|
}
|
|
|
|
// EntitlementHistoryStore adapts Store to the existing
|
|
// EntitlementHistoryStore port.
|
|
type EntitlementHistoryStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// EntitlementHistory returns one adapter that exposes the entitlement-history
|
|
// store port over Store.
|
|
func (store *Store) EntitlementHistory() *EntitlementHistoryStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &EntitlementHistoryStore{store: store}
|
|
}
|
|
|
|
// Create stores one new entitlement history record.
|
|
func (adapter *EntitlementHistoryStore) Create(ctx context.Context, record entitlement.PeriodRecord) error {
|
|
return adapter.store.CreateEntitlementRecord(ctx, record)
|
|
}
|
|
|
|
// GetByRecordID returns the entitlement history record identified by recordID.
|
|
func (adapter *EntitlementHistoryStore) GetByRecordID(
|
|
ctx context.Context,
|
|
recordID entitlement.EntitlementRecordID,
|
|
) (entitlement.PeriodRecord, error) {
|
|
return adapter.store.GetEntitlementRecordByRecordID(ctx, recordID)
|
|
}
|
|
|
|
// ListByUserID returns every entitlement history record owned by userID.
|
|
func (adapter *EntitlementHistoryStore) ListByUserID(
|
|
ctx context.Context,
|
|
userID common.UserID,
|
|
) ([]entitlement.PeriodRecord, error) {
|
|
return adapter.store.ListEntitlementRecordsByUserID(ctx, userID)
|
|
}
|
|
|
|
// Update replaces one stored entitlement history record.
|
|
func (adapter *EntitlementHistoryStore) Update(ctx context.Context, record entitlement.PeriodRecord) error {
|
|
return adapter.store.UpdateEntitlementRecord(ctx, record)
|
|
}
|
|
|
|
var _ ports.EntitlementHistoryStore = (*EntitlementHistoryStore)(nil)
|
|
|
|
// EntitlementLifecycleStore adapts Store to the existing
|
|
// EntitlementLifecycleStore port.
|
|
type EntitlementLifecycleStore struct {
|
|
store *Store
|
|
}
|
|
|
|
// EntitlementLifecycle returns one adapter that exposes the atomic
|
|
// entitlement-lifecycle store port over Store.
|
|
func (store *Store) EntitlementLifecycle() *EntitlementLifecycleStore {
|
|
if store == nil {
|
|
return nil
|
|
}
|
|
|
|
return &EntitlementLifecycleStore{store: store}
|
|
}
|
|
|
|
// Grant atomically applies one free-to-paid transition.
|
|
func (adapter *EntitlementLifecycleStore) Grant(ctx context.Context, input ports.GrantEntitlementInput) error {
|
|
return adapter.store.GrantEntitlement(ctx, input)
|
|
}
|
|
|
|
// Extend atomically appends one paid extension segment and updates the current
|
|
// snapshot.
|
|
func (adapter *EntitlementLifecycleStore) Extend(ctx context.Context, input ports.ExtendEntitlementInput) error {
|
|
return adapter.store.ExtendEntitlement(ctx, input)
|
|
}
|
|
|
|
// Revoke atomically applies one paid-to-free transition.
|
|
func (adapter *EntitlementLifecycleStore) Revoke(ctx context.Context, input ports.RevokeEntitlementInput) error {
|
|
return adapter.store.RevokeEntitlement(ctx, input)
|
|
}
|
|
|
|
// RepairExpired atomically repairs one expired finite paid snapshot.
|
|
func (adapter *EntitlementLifecycleStore) RepairExpired(
|
|
ctx context.Context,
|
|
input ports.RepairExpiredEntitlementInput,
|
|
) error {
|
|
return adapter.store.RepairExpiredEntitlement(ctx, input)
|
|
}
|
|
|
|
var _ ports.EntitlementLifecycleStore = (*EntitlementLifecycleStore)(nil)
|