Files
sharang 8fa1a1bffd
ci / shared (push) Successful in 6s
ci / test (push) Successful in 1m42s
ci / image (push) Has been skipped
feat(store): set trial_ends_at on tenant create
trial_ends_at = NOW()+14d for customer kind; demo kind gets status=demo and no end. Unblocks M12.1 portal banner.

Refs: M4.1 + M12.1 prep
2026-05-19 16:27:09 +00:00

348 lines
8.7 KiB
Go

package store
import (
"context"
"sort"
"sync"
"time"
"github.com/google/uuid"
)
// Memory — in-process Store used when DATABASE_URL is empty. Convenient for
// local dev when you don't want to bring up Postgres + run migrations.
// Pre-seeded with the acme tenant.
type Memory struct {
mu sync.RWMutex
tenants map[string]*Tenant // id → tenant
bySlug map[string]string // slug → id
products map[string]map[string]*TenantProduct // tenant_id → product → row
apiKeys map[string]*apiKeyWithHash // id → key
byPrefix map[string]string // prefix → id
audit []*AuditEvent
auditID int64
}
type apiKeyWithHash struct {
APIKey
Hash string
}
// NewMemory returns a fresh in-memory store with the seed acme tenant.
func NewMemory() *Memory {
m := &Memory{
tenants: make(map[string]*Tenant),
bySlug: make(map[string]string),
products: make(map[string]map[string]*TenantProduct),
apiKeys: make(map[string]*apiKeyWithHash),
byPrefix: make(map[string]string),
}
now := time.Now().UTC()
seed := &Tenant{
ID: "00000000-0000-0000-0000-000000000001",
Slug: "acme",
Name: "Acme Inc.",
Status: "active",
Kind: "customer",
Plan: "professional",
CreatedAt: now,
UpdatedAt: now,
}
m.tenants[seed.ID] = seed
m.bySlug[seed.Slug] = seed.ID
m.products[seed.ID] = map[string]*TenantProduct{
"certifai": {TenantID: seed.ID, Product: "certifai", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
"compliance": {TenantID: seed.ID, Product: "compliance", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now},
}
return m
}
func (m *Memory) Close() {}
func (m *Memory) Ping(_ context.Context) error { return nil }
// ─── tenants ──────────────────────────────────────────────────────────────
func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, taken := m.bySlug[in.Slug]; taken {
return nil, ErrConflict
}
now := time.Now().UTC()
kind := firstNonEmpty(in.Kind, "customer")
status := "trial"
var trialEnds *time.Time
if kind == "demo" {
status = "demo"
} else {
end := now.Add(14 * 24 * time.Hour)
trialEnds = &end
}
t := &Tenant{
ID: uuid.NewString(),
Slug: in.Slug,
Name: in.Name,
Status: status,
Kind: kind,
Plan: firstNonEmpty(in.Plan, "starter"),
SalesOwner: in.SalesOwner,
TrialEndsAt: trialEnds,
CreatedAt: now,
UpdatedAt: now,
}
m.tenants[t.ID] = t
m.bySlug[t.Slug] = t.ID
cp := *t
return &cp, nil
}
func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) {
m.mu.RLock()
defer m.mu.RUnlock()
t, ok := m.tenants[id]
if !ok {
return nil, ErrNotFound
}
cp := *t
return &cp, nil
}
func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) {
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.bySlug[slug]
if !ok {
return nil, ErrNotFound
}
t := m.tenants[id]
cp := *t
return &cp, nil
}
func (m *Memory) UpdateTenant(_ context.Context, id string, in TenantUpdate) (*Tenant, error) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tenants[id]
if !ok {
return nil, ErrNotFound
}
if in.Status != nil {
t.Status = *in.Status
}
if in.Plan != nil {
t.Plan = *in.Plan
}
if in.ErpCustomerID != nil {
t.ErpCustomerID = *in.ErpCustomerID
}
if in.StripeCustID != nil {
t.StripeCustID = *in.StripeCustID
}
if in.TrialEndsAt != nil {
t.TrialEndsAt = in.TrialEndsAt
}
if in.ContractStart != nil {
t.ContractStart = in.ContractStart
}
if in.ContractEnd != nil {
t.ContractEnd = in.ContractEnd
}
if in.SalesOwner != nil {
t.SalesOwner = *in.SalesOwner
}
t.UpdatedAt = time.Now().UTC()
cp := *t
return &cp, nil
}
// ─── entitlements ─────────────────────────────────────────────────────────
func (m *Memory) UpsertTenantProduct(_ context.Context, tp TenantProduct) (*TenantProduct, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[tp.TenantID]; !ok {
return nil, ErrNotFound
}
if _, ok := m.products[tp.TenantID]; !ok {
m.products[tp.TenantID] = map[string]*TenantProduct{}
}
now := time.Now().UTC()
stored, exists := m.products[tp.TenantID][tp.Product]
if !exists {
stored = &TenantProduct{
TenantID: tp.TenantID,
Product: tp.Product,
CreatedAt: now,
}
m.products[tp.TenantID][tp.Product] = stored
}
stored.Enabled = tp.Enabled
stored.Config = tp.Config
stored.ExpiresAt = tp.ExpiresAt
stored.UpdatedAt = now
cp := *stored
return &cp, nil
}
func (m *Memory) ListTenantProducts(_ context.Context, tenantID string) ([]TenantProduct, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrNotFound
}
out := make([]TenantProduct, 0, len(m.products[tenantID]))
for _, p := range m.products[tenantID] {
out = append(out, *p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Product < out[j].Product })
return out, nil
}
// ─── api keys ─────────────────────────────────────────────────────────────
func (m *Memory) CreateAPIKey(_ context.Context, in APIKeyCreate) (*APIKey, error) {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.tenants[in.TenantID]; !ok {
return nil, ErrNotFound
}
now := time.Now().UTC()
k := &apiKeyWithHash{
APIKey: APIKey{
ID: uuid.NewString(),
TenantID: in.TenantID,
Product: in.Product,
Name: in.Name,
Scopes: append([]string{}, in.Scopes...),
Prefix: in.Prefix,
CreatedBy: in.CreatedBy,
CreatedAt: now,
},
Hash: in.Hash,
}
m.apiKeys[k.ID] = k
m.byPrefix[k.Prefix] = k.ID
cp := k.APIKey
return &cp, nil
}
func (m *Memory) FindAPIKeyByPrefix(_ context.Context, prefix string) (*APIKey, string, error) {
m.mu.RLock()
defer m.mu.RUnlock()
id, ok := m.byPrefix[prefix]
if !ok {
return nil, "", ErrNotFound
}
k := m.apiKeys[id]
if k.RevokedAt != nil {
return nil, "", ErrNotFound
}
cp := k.APIKey
return &cp, k.Hash, nil
}
func (m *Memory) TouchAPIKeyUsed(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
k, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
k.LastUsedAt = &now
return nil
}
func (m *Memory) RevokeAPIKey(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
k, ok := m.apiKeys[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
k.RevokedAt = &now
return nil
}
func (m *Memory) ListAPIKeys(_ context.Context, tenantID string) ([]APIKey, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if _, ok := m.tenants[tenantID]; !ok {
return nil, ErrNotFound
}
out := []APIKey{}
for _, k := range m.apiKeys {
if k.TenantID == tenantID {
out = append(out, k.APIKey)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
// ─── audit ────────────────────────────────────────────────────────────────
func (m *Memory) AppendAudit(_ context.Context, ev AuditEvent) (*AuditEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.auditID++
ev.ID = m.auditID
ev.CreatedAt = time.Now().UTC()
cp := ev
m.audit = append(m.audit, &cp)
out := ev
return &out, nil
}
func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]AuditEvent, int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
limit := f.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
matches := []AuditEvent{}
for _, ev := range m.audit {
if ev.ID <= f.Cursor {
continue
}
if f.TenantID != "" && ev.TenantID != f.TenantID {
continue
}
if f.Product != "" && ev.Product != f.Product {
continue
}
if f.ActorID != "" && ev.ActorID != f.ActorID {
continue
}
if f.Action != "" && ev.Action != f.Action {
continue
}
if f.Since != nil && ev.CreatedAt.Before(*f.Since) {
continue
}
if f.Until != nil && ev.CreatedAt.After(*f.Until) {
continue
}
matches = append(matches, *ev)
if len(matches) >= limit {
break
}
}
var nextCursor int64
if len(matches) == limit && len(matches) > 0 {
nextCursor = matches[len(matches)-1].ID
}
return matches, nextCursor, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}