Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package aiprompts
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidKey = errors.New("invalid prompt key")
|
||||
ErrInvalidInput = errors.New("invalid prompt input")
|
||||
)
|
||||
|
||||
// ClientError maps known client errors to safe API messages.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidKey):
|
||||
return "invalid prompt key", true
|
||||
case errors.Is(err, ErrInvalidInput):
|
||||
return "invalid prompt templates", true
|
||||
default:
|
||||
// Wrapped ErrInvalidKey / ErrInvalidInput from fmt.Errorf("%w: …")
|
||||
if errors.Is(err, ErrInvalidKey) {
|
||||
return err.Error(), true
|
||||
}
|
||||
if errors.Is(err, ErrInvalidInput) {
|
||||
return err.Error(), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package aiprompts
|
||||
|
||||
// Prompt keys stored in ai_prompt_templates.prompt_key.
|
||||
const (
|
||||
KeyProductEnhance = "product_enhance"
|
||||
KeySEOMeta = "seo_meta"
|
||||
KeyCampaignEmail = "campaign_email"
|
||||
)
|
||||
|
||||
// MaxSystemRunes / MaxUserRunes bound stored templates (prompt-injection surface).
|
||||
const (
|
||||
MaxSystemRunes = 6000
|
||||
MaxUserRunes = 4000
|
||||
)
|
||||
|
||||
// Variable describes a placeholder tenants can insert into templates.
|
||||
type Variable struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Keys []string `json:"keys"` // prompt_key values that support this variable
|
||||
}
|
||||
|
||||
// Catalog of supported {{variables}} (only these are substituted; unknown tokens stay literal).
|
||||
var VariableCatalog = []Variable{
|
||||
{Name: "name", Label: "Product name", Description: "Current product title", Keys: []string{KeyProductEnhance, KeySEOMeta}},
|
||||
{Name: "description", Label: "Description", Description: "Current product description", Keys: []string{KeyProductEnhance, KeySEOMeta}},
|
||||
{Name: "category", Label: "Category", Description: "Resolved category name", Keys: []string{KeyProductEnhance, KeySEOMeta}},
|
||||
{Name: "attrs", Label: "Attributes", Description: "Compact JSON of product attributes", Keys: []string{KeyProductEnhance}},
|
||||
{Name: "gtin", Label: "GTIN", Description: "Product GTIN / barcode when present", Keys: []string{KeyProductEnhance}},
|
||||
{Name: "brand", Label: "Brand name", Description: "Company or product brand label", Keys: []string{KeySEOMeta, KeyCampaignEmail}},
|
||||
{Name: "brand_voice", Label: "Brand voice", Description: "Brand kit tone / dos / don'ts block", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
|
||||
{Name: "language", Label: "Content language", Description: "Company content language (English display name)", Keys: []string{KeyProductEnhance, KeySEOMeta, KeyCampaignEmail}},
|
||||
{Name: "campaign_prompt", Label: "Campaign brief", Description: "Per-campaign user brief or template default", Keys: []string{KeyCampaignEmail}},
|
||||
{Name: "products", Label: "Product list", Description: "Plain-text product snippets for the campaign", Keys: []string{KeyCampaignEmail}},
|
||||
{Name: "template_key", Label: "Template key", Description: "Campaign template id (christmas, custom, …)", Keys: []string{KeyCampaignEmail}},
|
||||
}
|
||||
|
||||
// DefaultTemplate is the built-in prompt when the company has no custom row or disabled it.
|
||||
type DefaultTemplate struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
SystemTemplate string `json:"system_template"`
|
||||
UserTemplate string `json:"user_template"`
|
||||
}
|
||||
|
||||
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
|
||||
var BuiltInDefaults = []DefaultTemplate{
|
||||
{
|
||||
Key: KeyProductEnhance,
|
||||
Label: "Product title & description",
|
||||
Description: "Used when processing products (AI enhance step).",
|
||||
SystemTemplate: `Retail product copywriter.
|
||||
Rules:
|
||||
- Reply with ONLY JSON (no markdown)
|
||||
- Schema: {"name":"string","description":"string"}
|
||||
- name: short retail title
|
||||
- description: 1-2 factual sentences
|
||||
- Write name and description in {{language}}
|
||||
Example:
|
||||
{"name":"Acme Widget Pro","description":"Durable widget for everyday use. Clear specs, ready to ship."}
|
||||
{{brand_voice}}`,
|
||||
UserTemplate: `Category: {{category}}
|
||||
Name: {{name}}
|
||||
Desc: {{description}}
|
||||
Attrs: {{attrs}}`,
|
||||
},
|
||||
{
|
||||
Key: KeySEOMeta,
|
||||
Label: "SEO meta title & description",
|
||||
Description: "Used when applying AI SEO meta to a product.",
|
||||
SystemTemplate: `SEO meta writer for ecommerce.
|
||||
Rules:
|
||||
- Reply with ONLY JSON (no markdown)
|
||||
- Schema: {"meta_title":"string","meta_description":"string"}
|
||||
- meta_title: 50-60 chars, product + benefit
|
||||
- meta_description: 120-155 chars, factual
|
||||
- Write meta_title and meta_description in {{language}}
|
||||
Example:
|
||||
{"meta_title":"Acme Widget Pro | Durable Daily Use","meta_description":"Shop Acme Widget Pro for reliable everyday performance. Clear specs and fast delivery."}
|
||||
{{brand_voice}}`,
|
||||
UserTemplate: `Name: {{name}}
|
||||
Category: {{category}}
|
||||
Desc: {{description}}`,
|
||||
},
|
||||
{
|
||||
Key: KeyCampaignEmail,
|
||||
Label: "Campaign email",
|
||||
Description: "Used when generating marketing emails with AI.",
|
||||
SystemTemplate: `Marketing email writer.
|
||||
Rules:
|
||||
- Reply with ONLY JSON (no markdown)
|
||||
- Schema: {"subject":"string","html_body":"string","plain_body":"string"}
|
||||
- subject: short
|
||||
- html_body: simple HTML (<p>, <ul>, <a> only)
|
||||
- plain_body: plain text mirror
|
||||
- Write subject, html_body, and plain_body in {{language}}
|
||||
Example:
|
||||
{"subject":"Holiday picks from Acme","html_body":"<p>Season's greetings.</p><ul><li>Widget Pro</li></ul><p><a href=\"#\">Shop now</a></p>","plain_body":"Season's greetings.\n- Widget Pro\nShop now"}
|
||||
{{brand_voice}}`,
|
||||
UserTemplate: `{{campaign_prompt}}
|
||||
|
||||
Products:
|
||||
{{products}}
|
||||
Brand: {{brand}}
|
||||
Template: {{template_key}}`,
|
||||
},
|
||||
}
|
||||
|
||||
// ValidPromptKey reports whether key is a known prompt_key.
|
||||
func ValidPromptKey(key string) bool {
|
||||
switch key {
|
||||
case KeyProductEnhance, KeySEOMeta, KeyCampaignEmail:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultFor returns the built-in default for key, or empty if unknown.
|
||||
func DefaultFor(key string) (DefaultTemplate, bool) {
|
||||
for _, d := range BuiltInDefaults {
|
||||
if d.Key == key {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
return DefaultTemplate{}, false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package aiprompts
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
// varToken matches {{name}} with optional whitespace. Only [a-z0-9_]+ names.
|
||||
var varToken = regexp.MustCompile(`\{\{\s*([a-z][a-z0-9_]*)\s*\}\}`)
|
||||
|
||||
// Vars is the substitution map for Render (keys without braces).
|
||||
type Vars map[string]string
|
||||
|
||||
// Render replaces {{var}} tokens. Unknown variables become empty string (safe, deterministic).
|
||||
// Templates are sanitized before render; values should already be sanitized by callers.
|
||||
func Render(template string, vars Vars) string {
|
||||
template = strings.TrimSpace(template)
|
||||
if template == "" {
|
||||
return ""
|
||||
}
|
||||
return varToken.ReplaceAllStringFunc(template, func(match string) string {
|
||||
sub := varToken.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return ""
|
||||
}
|
||||
name := sub[1]
|
||||
if vars == nil {
|
||||
return ""
|
||||
}
|
||||
return vars[name]
|
||||
})
|
||||
}
|
||||
|
||||
// SanitizeTemplate cleans and bounds a stored prompt template.
|
||||
func SanitizeTemplate(s string, maxRunes int) string {
|
||||
return security.SanitizePrompt(s, maxRunes)
|
||||
}
|
||||
|
||||
// ExtractVariables returns unique variable names found in template (sorted order of first appearance).
|
||||
func ExtractVariables(template string) []string {
|
||||
matches := varToken.FindAllStringSubmatch(template, -1)
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
name := m[1]
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package aiprompts
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRender_replacesKnownVars(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Render("Hello {{name}} in {{category}}", Vars{
|
||||
"name": "Widget",
|
||||
"category": "Tools",
|
||||
})
|
||||
want := "Hello Widget in Tools"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_unknownVarEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Render("X={{missing}}Y", Vars{"name": "a"})
|
||||
if got != "X=Y" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_whitespaceInBraces(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Render("{{ name }}", Vars{"name": "ok"})
|
||||
if got != "ok" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ExtractVariables("{{name}} and {{name}} then {{brand_voice}}")
|
||||
if len(got) != 2 || got[0] != "name" || got[1] != "brand_voice" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPromptKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !ValidPromptKey(KeyProductEnhance) {
|
||||
t.Fatal("expected product_enhance valid")
|
||||
}
|
||||
if ValidPromptKey("nope") {
|
||||
t.Fatal("expected nope invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package aiprompts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Service loads and stores per-company AI prompt templates.
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool) *Service {
|
||||
return &Service{Pool: pool}
|
||||
}
|
||||
|
||||
type stored struct {
|
||||
key string
|
||||
language string
|
||||
systemTemplate string
|
||||
userTemplate string
|
||||
isEnabled bool
|
||||
updatedAt time.Time
|
||||
}
|
||||
|
||||
// GetBundle returns all prompt keys with effective templates for language + variable catalog.
|
||||
func (s *Service) GetBundle(ctx context.Context, companyID uuid.UUID, language string) (Bundle, error) {
|
||||
lang, err := company.ParseLanguage(language, true)
|
||||
if err != nil {
|
||||
lang = company.LoadLanguage(ctx, s.Pool, companyID)
|
||||
}
|
||||
contentLangs := company.LoadContentLanguages(ctx, s.Pool, companyID)
|
||||
storedRows, err := s.loadAll(ctx, companyID)
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
byKeyLang := map[string]stored{}
|
||||
customLangs := map[string][]string{}
|
||||
for _, st := range storedRows {
|
||||
byKeyLang[st.key+"\x00"+st.language] = st
|
||||
customLangs[st.key] = appendUnique(customLangs[st.key], st.language)
|
||||
}
|
||||
out := make([]Template, 0, len(BuiltInDefaults))
|
||||
for _, def := range BuiltInDefaults {
|
||||
t := Template{
|
||||
Key: def.Key,
|
||||
Language: lang,
|
||||
Label: def.Label,
|
||||
Description: def.Description,
|
||||
IsDefault: true,
|
||||
IsCustom: false,
|
||||
IsEnabled: true,
|
||||
}
|
||||
if st, ok := byKeyLang[def.Key+"\x00"+lang]; ok {
|
||||
t.IsCustom = true
|
||||
t.IsDefault = false
|
||||
t.IsEnabled = st.isEnabled
|
||||
t.UpdatedAt = st.updatedAt
|
||||
if st.isEnabled {
|
||||
t.SystemTemplate = st.systemTemplate
|
||||
t.UserTemplate = st.userTemplate
|
||||
} else {
|
||||
t.SystemTemplate = def.SystemTemplate
|
||||
t.UserTemplate = def.UserTemplate
|
||||
t.IsDefault = true
|
||||
}
|
||||
} else {
|
||||
t.SystemTemplate = def.SystemTemplate
|
||||
t.UserTemplate = def.UserTemplate
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return Bundle{
|
||||
Language: lang,
|
||||
Prompts: out,
|
||||
Variables: VariableCatalog,
|
||||
CustomLanguages: customLangs,
|
||||
ContentLanguages: contentLangs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Resolve returns the effective templates for one key + language
|
||||
// (custom if enabled for lang, else built-in). No cross-language company fallback.
|
||||
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID, key, language string) (Resolved, error) {
|
||||
if !ValidPromptKey(key) {
|
||||
return Resolved{}, ErrInvalidKey
|
||||
}
|
||||
def, ok := DefaultFor(key)
|
||||
if !ok {
|
||||
return Resolved{}, ErrInvalidKey
|
||||
}
|
||||
lang, err := company.ParseLanguage(language, true)
|
||||
if err != nil {
|
||||
lang = company.DefaultLanguage
|
||||
}
|
||||
st, err := s.loadOne(ctx, companyID, key, lang)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Resolved{}, err
|
||||
}
|
||||
if err == nil && st.isEnabled {
|
||||
sys := strings.TrimSpace(st.systemTemplate)
|
||||
user := strings.TrimSpace(st.userTemplate)
|
||||
if sys == "" {
|
||||
sys = def.SystemTemplate
|
||||
}
|
||||
if user == "" {
|
||||
user = def.UserTemplate
|
||||
}
|
||||
return Resolved{
|
||||
Key: key,
|
||||
Language: lang,
|
||||
SystemTemplate: sys,
|
||||
UserTemplate: user,
|
||||
IsCustom: true,
|
||||
}, nil
|
||||
}
|
||||
return Resolved{
|
||||
Key: key,
|
||||
Language: lang,
|
||||
SystemTemplate: def.SystemTemplate,
|
||||
UserTemplate: def.UserTemplate,
|
||||
IsCustom: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update applies prompt updates (upsert or reset). Empty prompts list is a no-op.
|
||||
func (s *Service) Update(ctx context.Context, companyID uuid.UUID, in UpdateInput) (Bundle, error) {
|
||||
defaultLang := strings.TrimSpace(in.Language)
|
||||
if defaultLang == "" {
|
||||
defaultLang = company.LoadLanguage(ctx, s.Pool, companyID)
|
||||
}
|
||||
if len(in.Prompts) == 0 {
|
||||
return s.GetBundle(ctx, companyID, defaultLang)
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
lastLang := defaultLang
|
||||
for _, item := range in.Prompts {
|
||||
key := strings.TrimSpace(strings.ToLower(item.Key))
|
||||
if !ValidPromptKey(key) {
|
||||
return Bundle{}, fmt.Errorf("%w: %s", ErrInvalidKey, item.Key)
|
||||
}
|
||||
langRaw := strings.TrimSpace(item.Language)
|
||||
if langRaw == "" {
|
||||
langRaw = defaultLang
|
||||
}
|
||||
lang, err := company.ParseLanguage(langRaw, false)
|
||||
if err != nil {
|
||||
return Bundle{}, fmt.Errorf("%w: language %q", ErrInvalidInput, langRaw)
|
||||
}
|
||||
lastLang = lang
|
||||
if item.Reset {
|
||||
_, err := tx.Exec(ctx, `
|
||||
DELETE FROM ai_prompt_templates
|
||||
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
|
||||
companyID, key, lang)
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
sys := SanitizeTemplate(item.SystemTemplate, MaxSystemRunes)
|
||||
user := SanitizeTemplate(item.UserTemplate, MaxUserRunes)
|
||||
if sys == "" && user == "" {
|
||||
return Bundle{}, fmt.Errorf("%w: empty templates for %s", ErrInvalidInput, key)
|
||||
}
|
||||
enabled := true
|
||||
if item.IsEnabled != nil {
|
||||
enabled = *item.IsEnabled
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO ai_prompt_templates (
|
||||
company_id, prompt_key, language, system_template, user_template, is_enabled, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6, now())
|
||||
ON CONFLICT (company_id, prompt_key, language) DO UPDATE SET
|
||||
system_template = EXCLUDED.system_template,
|
||||
user_template = EXCLUDED.user_template,
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
updated_at = now()`,
|
||||
companyID, key, lang, sys, user, enabled)
|
||||
if err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Bundle{}, err
|
||||
}
|
||||
return s.GetBundle(ctx, companyID, lastLang)
|
||||
}
|
||||
|
||||
func (s *Service) loadAll(ctx context.Context, companyID uuid.UUID) ([]stored, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
|
||||
FROM ai_prompt_templates WHERE company_id = $1`, companyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []stored
|
||||
for rows.Next() {
|
||||
var st stored
|
||||
if err := rows.Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) loadOne(ctx context.Context, companyID uuid.UUID, key, language string) (stored, error) {
|
||||
var st stored
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT prompt_key, language, system_template, user_template, is_enabled, updated_at
|
||||
FROM ai_prompt_templates
|
||||
WHERE company_id = $1 AND prompt_key = $2 AND language = $3`,
|
||||
companyID, key, language).Scan(&st.key, &st.language, &st.systemTemplate, &st.userTemplate, &st.isEnabled, &st.updatedAt)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func appendUnique(list []string, v string) []string {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return list
|
||||
}
|
||||
}
|
||||
return append(list, v)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package aiprompts
|
||||
|
||||
import "time"
|
||||
|
||||
// Template is one company's prompt for a feature key (+ language) (API / storage shape).
|
||||
type Template struct {
|
||||
Key string `json:"key"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
SystemTemplate string `json:"system_template"`
|
||||
UserTemplate string `json:"user_template"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateItem is one prompt in a PUT body.
|
||||
type UpdateItem struct {
|
||||
Key string `json:"key"`
|
||||
Language string `json:"language,omitempty"`
|
||||
SystemTemplate string `json:"system_template"`
|
||||
UserTemplate string `json:"user_template"`
|
||||
IsEnabled *bool `json:"is_enabled,omitempty"`
|
||||
Reset bool `json:"reset,omitempty"` // delete custom row → fall back to built-in
|
||||
}
|
||||
|
||||
// UpdateInput is the PUT /integrations/ai/prompts body.
|
||||
type UpdateInput struct {
|
||||
Language string `json:"language,omitempty"` // default language for items missing Language
|
||||
Prompts []UpdateItem `json:"prompts"`
|
||||
}
|
||||
|
||||
// Resolved is the effective system+user templates after defaults / custom merge.
|
||||
type Resolved struct {
|
||||
Key string
|
||||
Language string
|
||||
SystemTemplate string
|
||||
UserTemplate string
|
||||
IsCustom bool
|
||||
}
|
||||
|
||||
// Bundle is the GET response for the prompts UI.
|
||||
type Bundle struct {
|
||||
Language string `json:"language"`
|
||||
Prompts []Template `json:"prompts"`
|
||||
Variables []Variable `json:"variables"`
|
||||
CustomLanguages map[string][]string `json:"custom_languages"` // key → langs with overrides
|
||||
ContentLanguages []string `json:"content_languages,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package aiprovider
|
||||
|
||||
import "strings"
|
||||
|
||||
// Mode values stored on ai_providers.mode (UI / API).
|
||||
const (
|
||||
ModeInternal = "internal"
|
||||
ModePopular = "popular"
|
||||
ModeCustom = "custom"
|
||||
)
|
||||
|
||||
// ModeInternalLabel is the analytics / job recording value when using platform OpenAI (admin settings or env fallback).
|
||||
const ModeInternalLabel = "internal"
|
||||
|
||||
// ModeCustomLabel is the analytics value for custom OpenAI-compatible endpoints.
|
||||
const ModeCustomLabel = "custom"
|
||||
|
||||
// PopularProvider is a curated OpenAI-compatible catalog entry.
|
||||
type PopularProvider struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
BaseURL string `json:"base_url"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
// PopularCatalog lists OpenAI-compatible providers tenants can pick by API key only.
|
||||
var PopularCatalog = []PopularProvider{
|
||||
{
|
||||
Name: "openai",
|
||||
Label: "OpenAI",
|
||||
BaseURL: "https://api.openai.com/v1",
|
||||
DefaultModel: "gpt-4o-mini",
|
||||
Models: []string{"gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"},
|
||||
},
|
||||
{
|
||||
Name: "google",
|
||||
Label: "Google (Gemini OpenAI compat)",
|
||||
BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
DefaultModel: "gemini-2.0-flash",
|
||||
Models: []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.0-flash-lite"},
|
||||
},
|
||||
{
|
||||
Name: "groq",
|
||||
Label: "Groq",
|
||||
BaseURL: "https://api.groq.com/openai/v1",
|
||||
DefaultModel: "llama-3.3-70b-versatile",
|
||||
Models: []string{"llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768"},
|
||||
},
|
||||
{
|
||||
Name: "mistral",
|
||||
Label: "Mistral",
|
||||
BaseURL: "https://api.mistral.ai/v1",
|
||||
DefaultModel: "mistral-small-latest",
|
||||
Models: []string{"mistral-small-latest", "mistral-medium-latest", "mistral-large-latest"},
|
||||
},
|
||||
{
|
||||
Name: "deepseek",
|
||||
Label: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com/v1",
|
||||
DefaultModel: "deepseek-chat",
|
||||
Models: []string{"deepseek-chat", "deepseek-reasoner"},
|
||||
},
|
||||
{
|
||||
Name: "openrouter",
|
||||
Label: "OpenRouter",
|
||||
BaseURL: "https://openrouter.ai/api/v1",
|
||||
DefaultModel: "openai/gpt-4o-mini",
|
||||
Models: []string{"openai/gpt-4o-mini", "anthropic/claude-sonnet-4", "google/gemini-2.0-flash-001"},
|
||||
},
|
||||
}
|
||||
|
||||
func FindPopular(name string) (PopularProvider, bool) {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
for _, p := range PopularCatalog {
|
||||
if p.Name == name {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return PopularProvider{}, false
|
||||
}
|
||||
|
||||
// AnalyticsMode returns the recorded provider mode for jobs/products.
|
||||
// Contract for analytics: "internal" | "popular:<name>" | "custom"
|
||||
func AnalyticsMode(mode, popularName string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case ModePopular:
|
||||
name := strings.ToLower(strings.TrimSpace(popularName))
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return "popular:" + name
|
||||
case ModeCustom:
|
||||
return ModeCustomLabel
|
||||
default:
|
||||
return ModeInternalLabel
|
||||
}
|
||||
}
|
||||
|
||||
// AnalyticsClass maps a stored ai_provider_mode value to a rollup class.
|
||||
// Returns: internal | popular | custom | unknown
|
||||
func AnalyticsClass(modeLabel string) string {
|
||||
m := strings.ToLower(strings.TrimSpace(modeLabel))
|
||||
switch {
|
||||
case m == "" || m == "unknown":
|
||||
return "unknown"
|
||||
case m == ModeInternalLabel || m == ModeInternal:
|
||||
return ModeInternal
|
||||
case m == ModeCustomLabel || m == ModeCustom:
|
||||
return ModeCustom
|
||||
case strings.HasPrefix(m, "popular:"):
|
||||
return ModePopular
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeAnalyticsMode coerces free-form labels into the analytics contract.
|
||||
func NormalizeAnalyticsMode(modeLabel string) string {
|
||||
m := strings.ToLower(strings.TrimSpace(modeLabel))
|
||||
switch {
|
||||
case m == "" || m == "unknown":
|
||||
return "unknown"
|
||||
case m == ModeInternalLabel || m == ModeInternal:
|
||||
return ModeInternalLabel
|
||||
case m == ModeCustomLabel || m == ModeCustom:
|
||||
return ModeCustomLabel
|
||||
case strings.HasPrefix(m, "popular:"):
|
||||
name := strings.TrimSpace(strings.TrimPrefix(m, "popular:"))
|
||||
if name == "" {
|
||||
name = "unknown"
|
||||
}
|
||||
return "popular:" + name
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case ModePopular:
|
||||
return ModePopular
|
||||
case ModeCustom:
|
||||
return ModeCustom
|
||||
default:
|
||||
return ModeInternal
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package aiprovider
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAnalyticsClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "unknown"},
|
||||
{"unknown", "unknown"},
|
||||
{"internal", "internal"},
|
||||
{"custom", "custom"},
|
||||
{"popular:openai", "popular"},
|
||||
{"popular:groq", "popular"},
|
||||
{"POPULAR:openai", "popular"},
|
||||
{"weird", "unknown"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := AnalyticsClass(c.in); got != c.want {
|
||||
t.Fatalf("AnalyticsClass(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAnalyticsMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := NormalizeAnalyticsMode("popular:"); got != "popular:unknown" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := NormalizeAnalyticsMode("CUSTOM"); got != "custom" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
|
||||
)
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
// DeriveKey builds a 32-byte AES key. Prefer APP_ENCRYPTION_KEY /
|
||||
// CREDENTIALS_ENCRYPTION_KEY; falls back to DATABASE_URL material (local/dev).
|
||||
// In production, explicitKey is required; empty returns nil (fail closed).
|
||||
func DeriveKey(explicitKey, fallbackMaterial string) []byte {
|
||||
explicitKey = strings.TrimSpace(explicitKey)
|
||||
if explicitKey != "" {
|
||||
if b, err := decodeKeyMaterial(explicitKey); err == nil {
|
||||
return b
|
||||
}
|
||||
sum := sha256.Sum256([]byte(explicitKey))
|
||||
return sum[:]
|
||||
}
|
||||
if config.IsProductionEnv() {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256([]byte("descrybe-ai-v1|" + fallbackMaterial))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func decodeKeyMaterial(s string) ([]byte, error) {
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := hex.DecodeString(s); err == nil && len(b) == 32 {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("invalid key material")
|
||||
}
|
||||
|
||||
func EncryptSecret(key []byte, plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func DecryptSecret(key []byte, stored string) (string, error) {
|
||||
if stored == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(stored, encPrefix) {
|
||||
if config.IsProductionEnv() {
|
||||
return "", errors.New("plaintext secrets are not allowed when APP_ENV=production")
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("encryption key must be 32 bytes")
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
func last4(secret string) string {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(secret)
|
||||
if len(runes) <= 4 {
|
||||
return string(runes)
|
||||
}
|
||||
return string(runes[len(runes)-4:])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
// clientError is a validation message safe to return to API clients.
|
||||
type clientError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.msg }
|
||||
|
||||
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
|
||||
func ClientMsg(msg string) error {
|
||||
return &clientError{msg: msg}
|
||||
}
|
||||
|
||||
// ClientError reports whether err is a known client-facing AI provider error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.msg, true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotConfigured),
|
||||
errors.Is(err, ErrInvalidMode),
|
||||
errors.Is(err, ErrInvalidPopular),
|
||||
errors.Is(err, ErrMissingAPIKey),
|
||||
errors.Is(err, ErrMissingModel),
|
||||
errors.Is(err, ErrMissingURL):
|
||||
return err.Error(), true
|
||||
case errors.Is(err, security.ErrInvalidURL),
|
||||
errors.Is(err, security.ErrBlockedURL),
|
||||
errors.Is(err, security.ErrBlockedHost):
|
||||
return "invalid base_url", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
)
|
||||
|
||||
func TestTestPlatformRole_unknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := NewService(nil, EnvConfig{})
|
||||
res, err := svc.TestPlatformRole(context.Background(), "nope")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if res["status"] != "failed" {
|
||||
t.Fatalf("status=%v", res["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestPlatformRole_skippedWhenUnset(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := NewService(nil, EnvConfig{})
|
||||
svc.Platform = platformsettings.NewService(nil, platformsettings.EnvConfig{})
|
||||
res, err := svc.TestPlatformRole(context.Background(), RoleSupport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res["status"] != "skipped" {
|
||||
t.Fatalf("status=%v message=%v", res["status"], res["message"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestPlatformRole_chatProbeOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer sk-test-") {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"choices": []map[string]any{
|
||||
{"message": map[string]any{"content": "ok"}},
|
||||
},
|
||||
"usage": map[string]any{"total_tokens": 1},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
|
||||
OpenAIAPIKey: "sk-test-platform",
|
||||
OpenAIBaseURL: srv.URL + "/v1",
|
||||
OpenAIModel: "test-model",
|
||||
})
|
||||
svc := NewService(nil, EnvConfig{})
|
||||
svc.Platform = plat
|
||||
|
||||
res, err := svc.TestPlatformRole(context.Background(), RoleProcessing)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v res=%v", err, res)
|
||||
}
|
||||
if res["status"] != "ok" {
|
||||
t.Fatalf("status=%v message=%v", res["status"], res["message"])
|
||||
}
|
||||
if msg, _ := res["message"].(string); strings.Contains(strings.ToLower(msg), "sk-") {
|
||||
t.Fatalf("message must not leak key fragments: %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestPlatformRole_embedProbeOK(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/embeddings" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": []map[string]any{
|
||||
{"embedding": []float32{0.1, 0.2}, "index": 0},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
|
||||
OpenAIEmbeddingAPIKey: "sk-test-embed",
|
||||
OpenAIEmbeddingBaseURL: srv.URL + "/v1",
|
||||
OpenAIEmbeddingModel: "text-embedding-3-small",
|
||||
})
|
||||
svc := NewService(nil, EnvConfig{})
|
||||
svc.Platform = plat
|
||||
|
||||
res, err := svc.TestPlatformRole(context.Background(), RoleVectorization)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v res=%v", err, res)
|
||||
}
|
||||
if res["status"] != "ok" {
|
||||
t.Fatalf("status=%v message=%v", res["status"], res["message"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
)
|
||||
|
||||
func TestResolvePlatformOpenAI_envOnly(t *testing.T) {
|
||||
svc := &Service{
|
||||
Env: EnvConfig{
|
||||
OpenAIAPIKey: "sk-env-fallback",
|
||||
OpenAIBaseURL: "https://api.openai.com/v1",
|
||||
OpenAIModel: "gpt-4o-mini",
|
||||
},
|
||||
}
|
||||
oi, err := svc.resolvePlatformOpenAI(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oi.APIKey != "sk-env-fallback" || oi.Source != platformsettings.SourceEnv {
|
||||
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
|
||||
}
|
||||
ok, err := svc.platformConfigured(context.Background())
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("configured=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlatformOpenAI_unset(t *testing.T) {
|
||||
svc := &Service{Env: EnvConfig{}}
|
||||
oi, err := svc.resolvePlatformOpenAI(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oi.APIKey != "" || oi.Source != platformsettings.SourceNone {
|
||||
t.Fatalf("got key=%q source=%q", oi.APIKey, oi.Source)
|
||||
}
|
||||
ok, err := svc.platformConfigured(context.Background())
|
||||
if err != nil || ok {
|
||||
t.Fatalf("configured=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlatformOpenAI_viaPlatformService(t *testing.T) {
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
|
||||
OpenAIAPIKey: "sk-from-plat-env",
|
||||
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
|
||||
OpenAIModel: "local-model",
|
||||
})
|
||||
svc := &Service{Platform: plat, Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win"}}
|
||||
oi, err := svc.resolvePlatformOpenAI(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if oi.APIKey != "sk-from-plat-env" {
|
||||
t.Fatalf("key=%q", oi.APIKey)
|
||||
}
|
||||
if oi.Source != platformsettings.SourceEnv {
|
||||
t.Fatalf("source=%q", oi.Source)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Role identifiers — keep in sync with platformsettings.AIRole* / processing.AIRole*.
|
||||
//
|
||||
// RoleSupport is a FUTURE config slot (platformsettings.ai_roles["support"]).
|
||||
// Resolving a Completer when the slot is configured is allowed for future
|
||||
// draft-assist UIs, but support.TryAutoReplyLLM must remain the only gate for
|
||||
// ticket auto-replies — and that stub currently refuses. Guided /docs Ask is
|
||||
// rule-based and must never use RoleSupport or RoleDocsAPI.
|
||||
const (
|
||||
RoleProcessing = processing.AIRoleProcessing
|
||||
RoleVectorization = processing.AIRoleVectorization
|
||||
RoleDocsAPI = processing.AIRoleDocsAPI
|
||||
RoleSupport = processing.AIRoleSupport
|
||||
)
|
||||
|
||||
// RoleEndpoint is a resolved OpenAI-compatible chat endpoint for one role.
|
||||
// Secrets are plaintext only in-process — never log or return to clients.
|
||||
type RoleEndpoint struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
UsingBYOK bool
|
||||
ModeLabel string
|
||||
}
|
||||
|
||||
// RoleEndpointSource looks up admin-configured role bindings (platform / company).
|
||||
// ok=false means the role is unset — callers must fall back.
|
||||
type RoleEndpointSource interface {
|
||||
LookupRole(ctx context.Context, companyID uuid.UUID, role string) (ep RoleEndpoint, ok bool, err error)
|
||||
}
|
||||
|
||||
// ResolveCompleterForRole prefers an injected RoleEndpointSource binding when set;
|
||||
// otherwise uses company BYOK then platformsettings.ResolveAIConfig for the role
|
||||
// (processing falls back to legacy openai JSON + OPENAI_* env when unset).
|
||||
func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
role = RoleProcessing
|
||||
}
|
||||
|
||||
if s != nil && s.Roles != nil {
|
||||
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" {
|
||||
return s.completerFromEndpoint(ep)
|
||||
}
|
||||
}
|
||||
|
||||
switch role {
|
||||
case RoleProcessing:
|
||||
// Full Resolve needs Pool for company BYOK; without Pool use platform/env only.
|
||||
if s != nil && s.Pool != nil {
|
||||
return s.ResolveCompleter(ctx, companyID)
|
||||
}
|
||||
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
|
||||
case RoleDocsAPI, RoleSupport:
|
||||
return s.resolvePlatformRoleCompleter(ctx, role)
|
||||
default:
|
||||
// Vectorization uses embeddings clients — not chat Completer.
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveEmbedderForRole returns an OpenAI-compatible Embedder for the
|
||||
// vectorization role (platformsettings.AIRoleVectorization) with env fallback.
|
||||
// Non-vectorization roles return (nil, nil). Unset config returns (nil, nil).
|
||||
func (s *Service) ResolveEmbedderForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Embedder, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
role = RoleVectorization
|
||||
}
|
||||
if role != RoleVectorization {
|
||||
return nil, nil
|
||||
}
|
||||
if s != nil && s.Roles != nil {
|
||||
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok && strings.TrimSpace(ep.APIKey) != "" {
|
||||
model := strings.TrimSpace(ep.Model)
|
||||
if model == "" {
|
||||
model = "text-embedding-3-small"
|
||||
}
|
||||
rpm, retries := 0, 3
|
||||
if s != nil {
|
||||
rpm = s.Env.ProcessingRPM
|
||||
retries = s.Env.ProcessingMaxRetries
|
||||
}
|
||||
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, model, rpm, retries)
|
||||
if s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
if s != nil && s.Platform != nil {
|
||||
return s.Platform.ResolveEmbedder(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolvePlatformRoleCompleter(ctx context.Context, role string) (processing.Completer, string, bool, error) {
|
||||
if s == nil {
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
if s.Platform != nil {
|
||||
cfg, err := s.Platform.ResolveAIConfig(ctx, role)
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.APIKey) != "" {
|
||||
if role != RoleProcessing && !cfg.Enabled {
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
model := strings.TrimSpace(cfg.Model)
|
||||
if model == "" && role == RoleProcessing {
|
||||
model = strings.TrimSpace(s.Env.OpenAIModel)
|
||||
}
|
||||
if model != "" {
|
||||
return s.completerFromEndpoint(RoleEndpoint{
|
||||
APIKey: cfg.APIKey,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: model,
|
||||
UsingBYOK: false,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
if role == RoleProcessing {
|
||||
key := strings.TrimSpace(s.Env.OpenAIAPIKey)
|
||||
model := strings.TrimSpace(s.Env.OpenAIModel)
|
||||
if key != "" && model != "" {
|
||||
return s.completerFromEndpoint(RoleEndpoint{
|
||||
APIKey: key,
|
||||
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
|
||||
Model: model,
|
||||
UsingBYOK: false,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) completerFromEndpoint(ep RoleEndpoint) (processing.Completer, string, bool, error) {
|
||||
rpm := 0
|
||||
retries := 0
|
||||
if s != nil {
|
||||
rpm = s.Env.ProcessingRPM
|
||||
retries = s.Env.ProcessingMaxRetries
|
||||
}
|
||||
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, ep.Model, rpm, retries)
|
||||
label := strings.TrimSpace(ep.ModeLabel)
|
||||
if label == "" {
|
||||
if ep.UsingBYOK {
|
||||
label = ModeCustom
|
||||
} else {
|
||||
label = ModeInternalLabel
|
||||
}
|
||||
}
|
||||
client.ModeLabel = label
|
||||
if s != nil && s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return client, label, ep.UsingBYOK, nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type stubRoleSource struct {
|
||||
ep RoleEndpoint
|
||||
ok bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubRoleSource) LookupRole(_ context.Context, _ uuid.UUID, _ string) (RoleEndpoint, bool, error) {
|
||||
return s.ep, s.ok, s.err
|
||||
}
|
||||
|
||||
func TestResolveCompleterForRole_unsetFallsBackToEnv(t *testing.T) {
|
||||
svc := &Service{
|
||||
Env: EnvConfig{
|
||||
OpenAIAPIKey: "sk-env-fallback",
|
||||
OpenAIBaseURL: "https://api.openai.com/v1",
|
||||
OpenAIModel: "gpt-4o-mini",
|
||||
},
|
||||
}
|
||||
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oc, ok := c.(*processing.OpenAIClient)
|
||||
if !ok || oc == nil || !oc.Enabled() {
|
||||
t.Fatalf("expected enabled OpenAIClient, got %T", c)
|
||||
}
|
||||
if label != ModeInternalLabel {
|
||||
t.Fatalf("label=%q", label)
|
||||
}
|
||||
if byok {
|
||||
t.Fatal("env fallback must not be BYOK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompleterForRole_usesRoleBindingWhenSet(t *testing.T) {
|
||||
svc := &Service{
|
||||
Env: EnvConfig{
|
||||
OpenAIAPIKey: "sk-should-not-win",
|
||||
OpenAIModel: "env-model",
|
||||
},
|
||||
Roles: stubRoleSource{
|
||||
ok: true,
|
||||
ep: RoleEndpoint{
|
||||
APIKey: "sk-role-processing",
|
||||
BaseURL: "https://role.example/v1",
|
||||
Model: "role-model",
|
||||
UsingBYOK: false,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
},
|
||||
},
|
||||
}
|
||||
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.New(), RoleProcessing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oc, ok := c.(*processing.OpenAIClient)
|
||||
if !ok || oc == nil {
|
||||
t.Fatalf("type=%T", c)
|
||||
}
|
||||
if oc.APIKey != "sk-role-processing" || oc.Model != "role-model" {
|
||||
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
|
||||
}
|
||||
if label != ModeInternalLabel || byok {
|
||||
t.Fatalf("label=%q byok=%v", label, byok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompleterForRole_platformProcessingRole(t *testing.T) {
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
|
||||
OpenAIAPIKey: "sk-plat-processing",
|
||||
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
|
||||
OpenAIModel: "plat-model",
|
||||
})
|
||||
svc := &Service{
|
||||
Platform: plat,
|
||||
Env: EnvConfig{OpenAIAPIKey: "sk-should-not-win", OpenAIModel: "env-model"},
|
||||
}
|
||||
c, label, byok, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleProcessing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oc, ok := c.(*processing.OpenAIClient)
|
||||
if !ok || oc == nil {
|
||||
t.Fatalf("type=%T", c)
|
||||
}
|
||||
if oc.APIKey != "sk-plat-processing" || oc.Model != "plat-model" {
|
||||
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
|
||||
}
|
||||
if label != ModeInternalLabel || byok {
|
||||
t.Fatalf("label=%q byok=%v", label, byok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompleterForRole_vectorizationUnsetNoChatFallback(t *testing.T) {
|
||||
svc := &Service{
|
||||
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
|
||||
}
|
||||
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleVectorization)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c != nil {
|
||||
t.Fatal("vectorization must not fall back to chat completer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEmbedderForRole_usesPlatformVectorization(t *testing.T) {
|
||||
plat := platformsettings.NewService(nil, platformsettings.EnvConfig{
|
||||
OpenAIAPIKey: "sk-embed-env",
|
||||
OpenAIBaseURL: "http://127.0.0.1:8767/v1",
|
||||
OpenAIEmbeddingModel: "text-embedding-3-small",
|
||||
})
|
||||
svc := &Service{Platform: plat}
|
||||
emb, err := svc.ResolveEmbedderForRole(context.Background(), uuid.Nil, RoleVectorization)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oc, ok := emb.(*processing.OpenAIClient)
|
||||
if !ok || oc == nil || !oc.Enabled() {
|
||||
t.Fatalf("expected OpenAIClient embedder, got %T", emb)
|
||||
}
|
||||
if oc.APIKey != "sk-embed-env" || oc.Model != "text-embedding-3-small" {
|
||||
t.Fatalf("key=%q model=%q", oc.APIKey, oc.Model)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCompleterForRole_supportUnsetNoEnvFallback(t *testing.T) {
|
||||
svc := &Service{
|
||||
Env: EnvConfig{OpenAIAPIKey: "sk-env", OpenAIModel: "m"},
|
||||
}
|
||||
c, _, _, err := svc.ResolveCompleterForRole(context.Background(), uuid.Nil, RoleSupport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c != nil {
|
||||
t.Fatal("unset support must not fall back to processing/env completer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotConfigured = errors.New("ai provider not configured")
|
||||
ErrInvalidMode = errors.New("mode must be internal, popular, or custom")
|
||||
ErrInvalidPopular = errors.New("unknown popular provider")
|
||||
ErrMissingAPIKey = errors.New("api key required")
|
||||
ErrMissingModel = errors.New("model required")
|
||||
ErrMissingURL = errors.New("base url required for custom provider")
|
||||
)
|
||||
|
||||
// aiProbeTimeout bounds admin/company connection tests so a hung provider cannot
|
||||
// hold the HTTP request for multi-retry OpenAI client durations.
|
||||
const aiProbeTimeout = 45 * time.Second
|
||||
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Key []byte
|
||||
Env EnvConfig
|
||||
// Platform is optional; when set, platform OpenAI is loaded from admin
|
||||
// settings (DB) with EnvConfig as bootstrap fallback.
|
||||
Platform *platformsettings.Service
|
||||
// Roles is optional admin role-binding lookup (processing / embeddings / …).
|
||||
// When nil or a role is unset, ResolveCompleterForRole falls back to Resolve.
|
||||
Roles RoleEndpointSource
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, env EnvConfig) *Service {
|
||||
keyMaterial := firstNonEmpty(env.AppEncryptionKey, env.CredentialsEncryptionKey, env.TokenSigningSecret)
|
||||
return &Service{
|
||||
Pool: pool,
|
||||
Key: DeriveKey(keyMaterial, env.DatabaseURL),
|
||||
Env: env,
|
||||
// HTTPClient is optional (tests). Production uses NewOpenAIClient's
|
||||
// SafeHTTPClient so dial-time SSRF applies; leave nil here so platform
|
||||
// OPENAI_BASE_URL loopback (local models) is not overwritten.
|
||||
}
|
||||
}
|
||||
|
||||
type stored struct {
|
||||
mode, popularName, baseURL, model, keyEnc, last4 string
|
||||
enabled bool
|
||||
lastTest *time.Time
|
||||
lastStatus *string
|
||||
}
|
||||
|
||||
func (s *Service) loadStored(ctx context.Context, companyID uuid.UUID) (stored, error) {
|
||||
var st stored
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled,
|
||||
last_test_at, last_test_status
|
||||
FROM ai_providers WHERE company_id = $1`, companyID).Scan(
|
||||
&st.mode, &st.popularName, &st.baseURL, &st.model, &st.keyEnc, &st.last4, &st.enabled,
|
||||
&st.lastTest, &st.lastStatus,
|
||||
)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func (s *Service) GetConfig(ctx context.Context, companyID uuid.UUID) (PublicConfig, error) {
|
||||
platformOK, err := s.platformConfigured(ctx)
|
||||
if err != nil {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
st, err := s.loadStored(ctx, companyID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PublicConfig{
|
||||
Mode: ModeInternal,
|
||||
Configured: false,
|
||||
IsEnabled: false,
|
||||
ActiveModeLabel: ModeInternalLabel,
|
||||
PlatformFallback: platformOK,
|
||||
PopularProviders: PopularCatalog,
|
||||
}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
hasKey := st.keyEnc != ""
|
||||
masked := ""
|
||||
if hasKey && st.last4 != "" {
|
||||
masked = "••••" + st.last4
|
||||
}
|
||||
active := ModeInternalLabel
|
||||
if st.enabled && hasKey && (st.mode == ModePopular || st.mode == ModeCustom) {
|
||||
active = AnalyticsMode(st.mode, st.popularName)
|
||||
}
|
||||
return PublicConfig{
|
||||
Mode: normalizeMode(st.mode),
|
||||
PopularName: st.popularName,
|
||||
BaseURL: st.baseURL,
|
||||
Model: st.model,
|
||||
IsEnabled: st.enabled,
|
||||
Configured: true,
|
||||
HasAPIKey: hasKey,
|
||||
APIKeyLast4: st.last4,
|
||||
APIKeyMasked: masked,
|
||||
LastTestAt: st.lastTest,
|
||||
LastTestStatus: st.lastStatus,
|
||||
ActiveModeLabel: active,
|
||||
PlatformFallback: platformOK,
|
||||
PopularProviders: PopularCatalog,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(ctx context.Context, companyID uuid.UUID, in UpdateInput) (PublicConfig, error) {
|
||||
mode := normalizeMode(in.Mode)
|
||||
if mode != ModeInternal && mode != ModePopular && mode != ModeCustom {
|
||||
return PublicConfig{}, ErrInvalidMode
|
||||
}
|
||||
|
||||
var existing stored
|
||||
existing, err := s.loadStored(ctx, companyID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
hasExisting := err == nil
|
||||
|
||||
keyEnc := ""
|
||||
last4v := ""
|
||||
if hasExisting {
|
||||
keyEnc = existing.keyEnc
|
||||
last4v = existing.last4
|
||||
}
|
||||
if in.ClearAPIKey {
|
||||
keyEnc = ""
|
||||
last4v = ""
|
||||
} else if strings.TrimSpace(in.APIKey) != "" {
|
||||
plain := strings.TrimSpace(in.APIKey)
|
||||
enc, err := EncryptSecret(s.Key, plain)
|
||||
if err != nil {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
keyEnc = enc
|
||||
last4v = last4(plain)
|
||||
}
|
||||
|
||||
popularName := ""
|
||||
baseURL := ""
|
||||
model := strings.TrimSpace(in.Model)
|
||||
|
||||
switch mode {
|
||||
case ModeInternal:
|
||||
// Platform fallback; company key optional/cleared when switching away from BYOK.
|
||||
if !in.IsEnabled {
|
||||
keyEnc = ""
|
||||
last4v = ""
|
||||
}
|
||||
case ModePopular:
|
||||
pop, ok := FindPopular(in.PopularName)
|
||||
if !ok {
|
||||
return PublicConfig{}, ErrInvalidPopular
|
||||
}
|
||||
popularName = pop.Name
|
||||
baseURL = pop.BaseURL
|
||||
if model == "" {
|
||||
model = pop.DefaultModel
|
||||
}
|
||||
if !modelAllowed(pop, model) {
|
||||
return PublicConfig{}, ClientMsg(fmt.Sprintf("model %q is not in the %s catalog (or leave blank for default)", model, pop.Name))
|
||||
}
|
||||
if in.IsEnabled && keyEnc == "" {
|
||||
return PublicConfig{}, ErrMissingAPIKey
|
||||
}
|
||||
case ModeCustom:
|
||||
normalized, err := validateProviderBaseURL(in.BaseURL)
|
||||
if err != nil {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
baseURL = normalized
|
||||
if model == "" {
|
||||
return PublicConfig{}, ErrMissingModel
|
||||
}
|
||||
if in.IsEnabled && keyEnc == "" {
|
||||
return PublicConfig{}, ErrMissingAPIKey
|
||||
}
|
||||
}
|
||||
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO ai_providers (
|
||||
company_id, mode, popular_name, base_url, model, api_key_enc, api_key_last4, is_enabled, updated_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
|
||||
ON CONFLICT (company_id) DO UPDATE SET
|
||||
mode = EXCLUDED.mode,
|
||||
popular_name = EXCLUDED.popular_name,
|
||||
base_url = EXCLUDED.base_url,
|
||||
model = EXCLUDED.model,
|
||||
api_key_enc = EXCLUDED.api_key_enc,
|
||||
api_key_last4 = EXCLUDED.api_key_last4,
|
||||
is_enabled = EXCLUDED.is_enabled,
|
||||
updated_at = now()`,
|
||||
companyID, mode, popularName, baseURL, model, keyEnc, last4v, in.IsEnabled && mode != ModeInternal)
|
||||
if err != nil {
|
||||
return PublicConfig{}, err
|
||||
}
|
||||
return s.GetConfig(ctx, companyID)
|
||||
}
|
||||
|
||||
func modelAllowed(pop PopularProvider, model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" || model == pop.DefaultModel {
|
||||
return true
|
||||
}
|
||||
for _, m := range pop.Models {
|
||||
if m == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Allow unknown model strings for popular providers (API may add models faster than catalog).
|
||||
return true
|
||||
}
|
||||
|
||||
func validateProviderBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", ErrMissingURL
|
||||
}
|
||||
normalized, err := security.ValidatePublicHTTPSURL(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if normalized == "" {
|
||||
return "", ErrMissingURL
|
||||
}
|
||||
return strings.TrimRight(normalized, "/"), nil
|
||||
}
|
||||
|
||||
// Resolve picks company BYOK completer when enabled+keyed, else platform AI
|
||||
// from admin settings (DB), with optional env fallback via Platform / Env.
|
||||
func (s *Service) Resolve(ctx context.Context, companyID uuid.UUID) (Resolved, error) {
|
||||
rpm := s.Env.ProcessingRPM
|
||||
retries := s.Env.ProcessingMaxRetries
|
||||
|
||||
st, err := s.loadStored(ctx, companyID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return Resolved{}, err
|
||||
}
|
||||
if err == nil && st.enabled && (st.mode == ModePopular || st.mode == ModeCustom) {
|
||||
key, derr := DecryptSecret(s.Key, st.keyEnc)
|
||||
if derr != nil {
|
||||
return Resolved{}, derr
|
||||
}
|
||||
if strings.TrimSpace(key) != "" && strings.TrimSpace(st.baseURL) != "" && strings.TrimSpace(st.model) != "" {
|
||||
client := processing.NewOpenAIClient(key, st.baseURL, st.model, rpm, retries)
|
||||
client.ModeLabel = AnalyticsMode(st.mode, st.popularName)
|
||||
if s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return Resolved{
|
||||
Completer: client,
|
||||
ModeLabel: client.ModeLabel,
|
||||
UsingBYOK: true,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
platform, err := s.resolvePlatformOpenAI(ctx)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
if strings.TrimSpace(platform.APIKey) == "" {
|
||||
return Resolved{ModeLabel: ModeInternalLabel, UsingBYOK: false}, nil
|
||||
}
|
||||
client := processing.NewOpenAIClient(
|
||||
platform.APIKey,
|
||||
platform.BaseURL,
|
||||
platform.Model,
|
||||
rpm,
|
||||
retries,
|
||||
)
|
||||
client.ModeLabel = ModeInternalLabel
|
||||
if s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return Resolved{
|
||||
Completer: client,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
UsingBYOK: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) platformConfigured(ctx context.Context) (bool, error) {
|
||||
oi, err := s.resolvePlatformOpenAI(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(oi.APIKey) != "", nil
|
||||
}
|
||||
|
||||
func (s *Service) resolvePlatformOpenAI(ctx context.Context) (platformsettings.ResolvedOpenAI, error) {
|
||||
if s.Platform != nil {
|
||||
return s.Platform.ResolveOpenAI(ctx)
|
||||
}
|
||||
out := platformsettings.ResolvedOpenAI{
|
||||
APIKey: strings.TrimSpace(s.Env.OpenAIAPIKey),
|
||||
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
|
||||
Model: strings.TrimSpace(s.Env.OpenAIModel),
|
||||
Source: platformsettings.SourceNone,
|
||||
}
|
||||
if out.APIKey != "" {
|
||||
out.Source = platformsettings.SourceEnv
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveCompleter implements processing.CompanyCompleterResolver (legacy callers).
|
||||
// Prefer ResolveCompleterForRole for new call sites.
|
||||
func (s *Service) ResolveCompleter(ctx context.Context, companyID uuid.UUID) (processing.Completer, string, bool, error) {
|
||||
r, err := s.Resolve(ctx, companyID)
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
return r.Completer, r.ModeLabel, r.UsingBYOK, nil
|
||||
}
|
||||
|
||||
// TestPlatformRole probes admin platform AI role credentials (not company BYOK).
|
||||
// Chat roles send a minimal completion; vectorization sends a one-token embed.
|
||||
// Never returns upstream error bodies (may contain key fragments).
|
||||
func (s *Service) TestPlatformRole(ctx context.Context, role string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
|
||||
defer cancel()
|
||||
role = strings.TrimSpace(role)
|
||||
out := map[string]any{"role": role}
|
||||
if role == "" || !platformsettings.ValidAIRole(role) {
|
||||
out["status"] = "failed"
|
||||
out["message"] = "unknown ai role"
|
||||
return out, fmt.Errorf("unknown ai role %q", role)
|
||||
}
|
||||
|
||||
if role == RoleVectorization {
|
||||
emb, err := s.ResolveEmbedderForRole(ctx, uuid.Nil, role)
|
||||
if err != nil {
|
||||
out["status"] = "failed"
|
||||
out["message"] = "provider resolve failed"
|
||||
return out, err
|
||||
}
|
||||
if emb == nil {
|
||||
out["status"] = "skipped"
|
||||
out["message"] = "Vectorization AI is not configured in admin platform settings"
|
||||
return out, nil
|
||||
}
|
||||
if _, err := emb.Embed(ctx, []string{"ping"}); err != nil {
|
||||
out["status"] = "failed"
|
||||
out["message"] = "connection failed — check vectorization provider, key, and model"
|
||||
return out, err
|
||||
}
|
||||
out["status"] = "ok"
|
||||
out["message"] = "Embeddings probe succeeded"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
completer, _, _, err := s.resolvePlatformRoleCompleter(ctx, role)
|
||||
if err != nil {
|
||||
out["status"] = "failed"
|
||||
out["message"] = "provider resolve failed"
|
||||
return out, err
|
||||
}
|
||||
if completer == nil {
|
||||
out["status"] = "skipped"
|
||||
out["message"] = "AI role is not configured (or disabled) in admin platform settings"
|
||||
return out, nil
|
||||
}
|
||||
if _, err := completer.Complete(ctx, "Reply with exactly: ok", "ping"); err != nil {
|
||||
out["status"] = "failed"
|
||||
out["message"] = "connection failed — check provider, key, base URL, and model"
|
||||
return out, err
|
||||
}
|
||||
out["status"] = "ok"
|
||||
out["message"] = "Connection probe succeeded"
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestConnection sends a minimal chat completion and records last_test_*.
|
||||
func (s *Service) TestConnection(ctx context.Context, companyID uuid.UUID) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, aiProbeTimeout)
|
||||
defer cancel()
|
||||
resolved, err := s.Resolve(ctx, companyID)
|
||||
status := "ok"
|
||||
message := "connection successful"
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
message = "provider resolve failed"
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
return map[string]any{"status": status, "message": message, "mode": ModeInternalLabel}, err
|
||||
}
|
||||
if resolved.Completer == nil {
|
||||
status = "failed"
|
||||
message = "no api key configured (company BYOK or admin platform settings)"
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, ErrNotConfigured
|
||||
}
|
||||
_, err = resolved.Completer.Complete(ctx, "Reply with exactly: ok", "ping")
|
||||
if err != nil {
|
||||
status = "failed"
|
||||
// TruncateError classifies transport/auth failures without leaking secrets.
|
||||
message = processing.TruncateError(err)
|
||||
if message == "" || message == "provider error (details redacted)" {
|
||||
message = "connection failed — check provider, key, base URL, and model"
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
return map[string]any{"status": status, "message": message, "mode": resolved.ModeLabel}, err
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE ai_providers SET last_test_at = now(), last_test_status = $2, updated_at = now()
|
||||
WHERE company_id = $1`, companyID, status)
|
||||
return map[string]any{
|
||||
"status": status,
|
||||
"message": message,
|
||||
"mode": resolved.ModeLabel,
|
||||
"byok": resolved.UsingBYOK,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package aiprovider
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "development")
|
||||
key := DeriveKey("test-ai-key-material", "fallback")
|
||||
enc, err := EncryptSecret(key, "sk-test-secret-value")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc == "" || enc == "sk-test-secret-value" {
|
||||
t.Fatalf("expected ciphertext, got %q", enc)
|
||||
}
|
||||
plain, err := DecryptSecret(key, enc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plain != "sk-test-secret-value" {
|
||||
t.Fatalf("got %q", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptSecret_plaintextPassthrough(t *testing.T) {
|
||||
// Legacy/migrated rows may store unprefixed plaintext in local/dev only.
|
||||
t.Setenv("APP_ENV", "development")
|
||||
key := DeriveKey("test-ai-key-material", "fallback")
|
||||
got, err := DecryptSecret(key, "sk-legacy-plain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "sk-legacy-plain" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptSecret_plaintextRejectedInProduction(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "production")
|
||||
key := DeriveKey("test-ai-key-material", "fallback")
|
||||
if _, err := DecryptSecret(key, "sk-legacy-plain"); err == nil {
|
||||
t.Fatal("expected plaintext decrypt rejected in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyticsMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode, name, want string
|
||||
}{
|
||||
{ModeInternal, "", "internal"},
|
||||
{ModePopular, "openai", "popular:openai"},
|
||||
{ModePopular, "Google", "popular:google"},
|
||||
{ModeCustom, "", "custom"},
|
||||
{"", "", "internal"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := AnalyticsMode(c.mode, c.name)
|
||||
if got != c.want {
|
||||
t.Fatalf("AnalyticsMode(%q,%q)=%q want %q", c.mode, c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLast4(t *testing.T) {
|
||||
if got := last4("sk-abcdefgh"); got != "efgh" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := last4("ab"); got != "ab" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPopular(t *testing.T) {
|
||||
p, ok := FindPopular("openai")
|
||||
if !ok || p.BaseURL == "" {
|
||||
t.Fatal("expected openai")
|
||||
}
|
||||
if _, ok := FindPopular("nope"); ok {
|
||||
t.Fatal("expected miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderBaseURL(t *testing.T) {
|
||||
ok, err := validateProviderBaseURL("https://api.openai.com/v1")
|
||||
if err != nil || ok == "" {
|
||||
t.Fatalf("want ok, got %q err=%v", ok, err)
|
||||
}
|
||||
if _, err := validateProviderBaseURL("http://169.254.169.254/"); err == nil {
|
||||
t.Fatal("expected metadata URL blocked")
|
||||
}
|
||||
if _, err := validateProviderBaseURL("http://192.168.1.1/v1"); err == nil {
|
||||
t.Fatal("expected private IP blocked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
)
|
||||
|
||||
// PublicConfig is the tenant-safe view (no raw secrets).
|
||||
type PublicConfig struct {
|
||||
Mode string `json:"mode"`
|
||||
PopularName string `json:"popular_name,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
Configured bool `json:"configured"`
|
||||
HasAPIKey bool `json:"has_api_key"`
|
||||
APIKeyLast4 string `json:"api_key_last4,omitempty"`
|
||||
APIKeyMasked string `json:"api_key_masked,omitempty"`
|
||||
LastTestAt *time.Time `json:"last_test_at,omitempty"`
|
||||
LastTestStatus *string `json:"last_test_status,omitempty"`
|
||||
ActiveModeLabel string `json:"active_mode_label"`
|
||||
PlatformFallback bool `json:"platform_fallback_available"`
|
||||
PopularProviders []PopularProvider `json:"popular_providers,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateInput is the PUT body. Empty api_key keeps the existing encrypted key.
|
||||
type UpdateInput struct {
|
||||
Mode string `json:"mode"`
|
||||
PopularName string `json:"popular_name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
APIKey string `json:"api_key"`
|
||||
IsEnabled bool `json:"is_enabled"`
|
||||
ClearAPIKey bool `json:"clear_api_key"`
|
||||
}
|
||||
|
||||
// Resolved is the runtime completer + analytics mode for one company job.
|
||||
type Resolved struct {
|
||||
Completer processing.Completer
|
||||
ModeLabel string // internal | popular:<name> | custom
|
||||
UsingBYOK bool // true when company key is used (skip managed token credits)
|
||||
}
|
||||
|
||||
type EnvConfig struct {
|
||||
AppEncryptionKey string
|
||||
CredentialsEncryptionKey string
|
||||
TokenSigningSecret string
|
||||
DatabaseURL string
|
||||
OpenAIAPIKey string // optional env bootstrap; prefer admin platform settings
|
||||
OpenAIBaseURL string
|
||||
OpenAIModel string
|
||||
ProcessingRPM int
|
||||
ProcessingMaxRetries int
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
|
||||
// APIKeyIdentity is the tenant binding resolved from a valid API key.
|
||||
type APIKeyIdentity struct {
|
||||
KeyID uuid.UUID
|
||||
CompanyID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
MembershipRole string // active membership role for the key owner (admin|member)
|
||||
}
|
||||
|
||||
// HashAPIKey returns a SHA-256 hex digest for O(1) api_keys.key_hash lookup.
|
||||
// Matches hashes written by dashboard key creation. Also used for invite tokens at rest.
|
||||
func HashAPIKey(raw string) string {
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AuthenticateAPIKey looks up a non-revoked key by hash and updates last_used_at.
|
||||
// Keys owned by inactive users or without an active company membership are rejected.
|
||||
// MembershipRole is returned so HTTP middleware can withhold company-admin powers
|
||||
// when the owner is no longer an admin (keys are admin-created; scopes/expiry columns
|
||||
// do not exist yet — empty/full privilege remains the default for admin-owned keys).
|
||||
func (s *Service) AuthenticateAPIKey(ctx context.Context, raw string) (APIKeyIdentity, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return APIKeyIdentity{}, ErrInvalidAPIKey
|
||||
}
|
||||
hash := HashAPIKey(raw)
|
||||
var id APIKeyIdentity
|
||||
var role string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT k.id, k.company_id, k.user_id, m.role
|
||||
FROM api_keys k
|
||||
INNER JOIN users u ON u.id = k.user_id AND u.is_active = true
|
||||
INNER JOIN memberships m ON m.user_id = k.user_id
|
||||
AND m.company_id = k.company_id
|
||||
AND m.status = 'active'
|
||||
WHERE k.key_hash = $1 AND k.revoked_at IS NULL`, hash).
|
||||
Scan(&id.KeyID, &id.CompanyID, &id.UserID, &role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return APIKeyIdentity{}, ErrInvalidAPIKey
|
||||
}
|
||||
if err != nil {
|
||||
return APIKeyIdentity{}, err
|
||||
}
|
||||
id.MembershipRole = NormalizeMembershipRole(role)
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE api_keys SET last_used_at = now(), updated_at = now() WHERE id = $1`, id.KeyID)
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashAPIKeyDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := HashAPIKey("dk_test_secret_value")
|
||||
b := HashAPIKey("dk_test_secret_value")
|
||||
if a != b {
|
||||
t.Fatalf("hash not deterministic")
|
||||
}
|
||||
if len(a) != 64 {
|
||||
t.Fatalf("expected sha256 hex length 64, got %d", len(a))
|
||||
}
|
||||
if HashAPIKey("other") == a {
|
||||
t.Fatal("different keys must not collide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashAPIKeyEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := HashAPIKey("")
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("empty input still hashes: len=%d", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrRegisterFieldsRequired = errors.New("email, password, and company name are required")
|
||||
ErrPasswordTooShort = errors.New("password must be at least 8 characters")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrNotCompanyMember = errors.New("not a member of company")
|
||||
ErrInviteNotFound = errors.New("invite not found")
|
||||
ErrEmailRequired = errors.New("email is required")
|
||||
ErrSyntheticEmail = errors.New("synthetic migration email cannot receive invites")
|
||||
ErrNotEligibleSetPassword = errors.New("user not eligible for set-password invite")
|
||||
ErrEmailMismatch = errors.New("signed-in email does not match invite email")
|
||||
)
|
||||
|
||||
// ClientError reports whether err is a known client-facing auth error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrRegisterFieldsRequired),
|
||||
errors.Is(err, ErrPasswordTooShort),
|
||||
errors.Is(err, ErrPasswordAlreadySet),
|
||||
errors.Is(err, ErrUserExists),
|
||||
errors.Is(err, ErrInviteInvalid),
|
||||
errors.Is(err, ErrInvalidCredentials),
|
||||
errors.Is(err, ErrMustSetPassword),
|
||||
errors.Is(err, ErrUserNotFound),
|
||||
errors.Is(err, ErrNotCompanyMember),
|
||||
errors.Is(err, ErrInviteNotFound),
|
||||
errors.Is(err, ErrTokenInvalid),
|
||||
errors.Is(err, ErrEmailRequired),
|
||||
errors.Is(err, ErrSyntheticEmail),
|
||||
errors.Is(err, ErrNotEligibleSetPassword),
|
||||
errors.Is(err, ErrEmailMismatch):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Invite is a pending company invite row (plaintext token returned once at creation; hashed at rest).
|
||||
type Invite struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func normalizeInviteRole(role string) string {
|
||||
role = strings.TrimSpace(strings.ToLower(role))
|
||||
if role == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return "member"
|
||||
}
|
||||
|
||||
// NormalizeMembershipRole maps invite/membership role strings to admin|member.
|
||||
// Unknown values collapse to member (safe default for invites).
|
||||
func NormalizeMembershipRole(role string) string {
|
||||
return normalizeInviteRole(role)
|
||||
}
|
||||
|
||||
// ErrInvalidMembershipRole is returned when a role is not exactly admin|member.
|
||||
var ErrInvalidMembershipRole = errors.New("invalid membership role")
|
||||
|
||||
// ParseMembershipRole accepts only admin|member (case-insensitive). Unlike
|
||||
// NormalizeMembershipRole it does not coerce unknown values to member — use for
|
||||
// PATCH/role updates where coercion would silently demote admins.
|
||||
func ParseMembershipRole(role string) (string, error) {
|
||||
role = strings.TrimSpace(strings.ToLower(role))
|
||||
switch role {
|
||||
case "admin", "member":
|
||||
return role, nil
|
||||
default:
|
||||
return "", ErrInvalidMembershipRole
|
||||
}
|
||||
}
|
||||
|
||||
// preferMembershipRole keeps admin on invite accept conflict (never demote admin→member).
|
||||
// Mirrors AcceptInvite ON CONFLICT role CASE.
|
||||
func preferMembershipRole(existing, invited string) string {
|
||||
if existing == "admin" {
|
||||
return "admin"
|
||||
}
|
||||
return normalizeInviteRole(invited)
|
||||
}
|
||||
|
||||
// HashInviteToken returns the SHA-256 hex digest stored in invites.token (same construction as API keys).
|
||||
func HashInviteToken(raw string) string {
|
||||
return HashAPIKey(raw)
|
||||
}
|
||||
|
||||
// IsSyntheticLegacyEmail reports Clerk-missing synthetic addresses that must not receive invites.
|
||||
func IsSyntheticLegacyEmail(email string) bool {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
return strings.HasSuffix(email, "@legacy.local")
|
||||
}
|
||||
|
||||
// EmailsEqual compares emails case-insensitively after trim.
|
||||
func EmailsEqual(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b))
|
||||
}
|
||||
|
||||
// ResolveInviteEmail returns the invitee email for a pending, unexpired invite token.
|
||||
func (s *Service) ResolveInviteEmail(ctx context.Context, token string) (string, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return "", ErrInviteInvalid
|
||||
}
|
||||
var (
|
||||
email string
|
||||
expiresAt time.Time
|
||||
acceptedAt *time.Time
|
||||
)
|
||||
tokenHash := HashInviteToken(token)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT email, expires_at, accepted_at
|
||||
FROM invites
|
||||
WHERE token = $1 OR token = $2
|
||||
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`, tokenHash, token).Scan(&email, &expiresAt, &acceptedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
|
||||
return "", ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(email)), nil
|
||||
}
|
||||
|
||||
// SetPasswordInvite is a one-time accept-invite token for a migrated user (plaintext returned once).
|
||||
type SetPasswordInvite struct {
|
||||
InviteID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
CompanyID uuid.UUID
|
||||
Role string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CreateInvite inserts a pending invite (token hashed at rest) and returns the plaintext token once.
|
||||
func (s *Service) CreateInvite(ctx context.Context, companyID, invitedBy uuid.UUID, email, role string) (Invite, string, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return Invite{}, "", ErrEmailRequired
|
||||
}
|
||||
role = normalizeInviteRole(role)
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return Invite{}, "", err
|
||||
}
|
||||
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
|
||||
var inv Invite
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO invites (company_id, email, role, token, invited_by, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, company_id, email, role, expires_at`,
|
||||
companyID, email, role, HashInviteToken(token), invitedBy, expires,
|
||||
).Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt)
|
||||
if err != nil {
|
||||
return Invite{}, "", err
|
||||
}
|
||||
return inv, token, nil
|
||||
}
|
||||
|
||||
// ListPendingInvites returns unaccepted, unexpired invites for a company.
|
||||
func (s *Service) ListPendingInvites(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Invite, int64, error) {
|
||||
const where = `company_id = $1 AND accepted_at IS NULL AND expires_at > now()`
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM invites WHERE `+where, companyID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, email, role, expires_at
|
||||
FROM invites
|
||||
WHERE `+where+`
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Invite
|
||||
for rows.Next() {
|
||||
var inv Invite
|
||||
if err := rows.Scan(&inv.ID, &inv.CompanyID, &inv.Email, &inv.Role, &inv.ExpiresAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, inv)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeInvite deletes a pending invite owned by the company.
|
||||
func (s *Service) RevokeInvite(ctx context.Context, companyID, inviteID uuid.UUID) error {
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM invites
|
||||
WHERE id = $1 AND company_id = $2 AND accepted_at IS NULL`, inviteID, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrInviteNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompanyName returns the display name for a company.
|
||||
func (s *Service) CompanyName(ctx context.Context, companyID uuid.UUID) (string, error) {
|
||||
var name string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT name FROM companies WHERE id = $1`, companyID).Scan(&name)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errors.New("company not found")
|
||||
}
|
||||
return name, err
|
||||
}
|
||||
|
||||
// UpdateMembershipRole sets an active membership role to admin or member.
|
||||
func (s *Service) UpdateMembershipRole(ctx context.Context, companyID, userID uuid.UUID, role string) (Membership, error) {
|
||||
role = normalizeInviteRole(role)
|
||||
var m Membership
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
UPDATE memberships
|
||||
SET role = $3, updated_at = now()
|
||||
WHERE company_id = $1 AND user_id = $2 AND status = 'active'
|
||||
RETURNING id, company_id, user_id, role, status`,
|
||||
companyID, userID, role,
|
||||
).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Membership{}, ErrNotCompanyMember
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
// IsPlatformAdmin reports whether the user has full platform admin privileges
|
||||
// (admin/developer or legacy is_platform_admin). support_staff is excluded.
|
||||
func (s *Service) IsPlatformAdmin(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.GetStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.FullAdmin, nil
|
||||
}
|
||||
|
||||
// UpdateProfile updates the user's display name.
|
||||
func (s *Service) UpdateProfile(ctx context.Context, userID uuid.UUID, name string) (User, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
var n *string
|
||||
if name != "" {
|
||||
n = &name
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users SET name = $2, updated_at = now() WHERE id = $1`, userID, n)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return s.GetUser(ctx, userID)
|
||||
}
|
||||
|
||||
// ListUsersNeedingPassword returns active users with must_set_password (migration cutover).
|
||||
func (s *Service) ListUsersNeedingPassword(ctx context.Context, limit int) ([]User, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users
|
||||
WHERE must_set_password = true AND is_active = true
|
||||
ORDER BY email
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ReissueSetPasswordInvite expires prior pending invites and creates a durable invite for a
|
||||
// must_set_password user with an active membership. Token plaintext is returned once.
|
||||
func (s *Service) ReissueSetPasswordInvite(ctx context.Context, userID uuid.UUID, ttl time.Duration) (SetPasswordInvite, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
var (
|
||||
out SetPasswordInvite
|
||||
mustSetPassword bool
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, must_set_password, is_active
|
||||
FROM users WHERE id = $1`, userID).Scan(&out.UserID, &out.Email, &mustSetPassword, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SetPasswordInvite{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
if !isActive || !mustSetPassword {
|
||||
return SetPasswordInvite{}, ErrNotEligibleSetPassword
|
||||
}
|
||||
if IsSyntheticLegacyEmail(out.Email) {
|
||||
return SetPasswordInvite{}, ErrSyntheticEmail
|
||||
}
|
||||
out.Email = strings.ToLower(strings.TrimSpace(out.Email))
|
||||
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT company_id, role
|
||||
FROM memberships
|
||||
WHERE user_id = $1 AND status = 'active'
|
||||
ORDER BY created_at
|
||||
LIMIT 1`, userID).Scan(&out.CompanyID, &out.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return SetPasswordInvite{}, ErrNotEligibleSetPassword
|
||||
}
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
out.Role = normalizeInviteRole(out.Role)
|
||||
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
out.Token = token
|
||||
out.ExpiresAt = time.Now().UTC().Add(ttl)
|
||||
|
||||
// Expire prior unaccepted invites for this email+company so re-issue is safe.
|
||||
if _, err := s.Pool.Exec(ctx, `
|
||||
UPDATE invites
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE company_id = $1 AND lower(email) = lower($2) AND accepted_at IS NULL`,
|
||||
out.CompanyID, out.Email); err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO invites (company_id, email, role, token, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id`,
|
||||
out.CompanyID, out.Email, out.Role, HashInviteToken(token), out.ExpiresAt,
|
||||
).Scan(&out.InviteID)
|
||||
if err != nil {
|
||||
return SetPasswordInvite{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreferMembershipRoleNeverDemotesAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
existing string
|
||||
invited string
|
||||
want string
|
||||
}{
|
||||
{name: "admin stays admin on member invite", existing: "admin", invited: "member", want: "admin"},
|
||||
{name: "admin stays admin on admin invite", existing: "admin", invited: "admin", want: "admin"},
|
||||
{name: "member promotes to admin", existing: "member", invited: "admin", want: "admin"},
|
||||
{name: "member stays member", existing: "member", invited: "member", want: "member"},
|
||||
{name: "unknown invited normalizes to member", existing: "member", invited: "owner", want: "member"},
|
||||
{name: "empty existing yields invited role", existing: "", invited: "admin", want: "admin"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := preferMembershipRole(tc.existing, tc.invited)
|
||||
if got != tc.want {
|
||||
t.Fatalf("preferMembershipRole(%q, %q)=%q want %q", tc.existing, tc.invited, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashInviteTokenMatchesAPIKeyHash(t *testing.T) {
|
||||
t.Parallel()
|
||||
const raw = "invite-plaintext-secret"
|
||||
got := HashInviteToken(raw)
|
||||
if got != HashAPIKey(raw) {
|
||||
t.Fatalf("HashInviteToken must match HashAPIKey construction")
|
||||
}
|
||||
if len(got) != 64 {
|
||||
t.Fatalf("expected sha256 hex length 64, got %d", len(got))
|
||||
}
|
||||
if got == raw {
|
||||
t.Fatal("invite token must not be stored as plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeInviteRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
if normalizeInviteRole("Admin") != "admin" {
|
||||
t.Fatal("expected admin")
|
||||
}
|
||||
if normalizeInviteRole(" MEMBER ") != "member" {
|
||||
t.Fatal("expected member")
|
||||
}
|
||||
if normalizeInviteRole("owner") != "member" {
|
||||
t.Fatal("unknown roles collapse to member")
|
||||
}
|
||||
if NormalizeMembershipRole("Admin") != "admin" {
|
||||
t.Fatal("NormalizeMembershipRole should accept Admin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMembershipRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ParseMembershipRole(" Admin ")
|
||||
if err != nil || got != "admin" {
|
||||
t.Fatalf("admin: got %q err=%v", got, err)
|
||||
}
|
||||
got, err = ParseMembershipRole("MEMBER")
|
||||
if err != nil || got != "member" {
|
||||
t.Fatalf("member: got %q err=%v", got, err)
|
||||
}
|
||||
if _, err := ParseMembershipRole("owner"); !errors.Is(err, ErrInvalidMembershipRole) {
|
||||
t.Fatalf("owner: err=%v want ErrInvalidMembershipRole", err)
|
||||
}
|
||||
if _, err := ParseMembershipRole(""); !errors.Is(err, ErrInvalidMembershipRole) {
|
||||
t.Fatalf("empty: err=%v want ErrInvalidMembershipRole", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSyntheticLegacyEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !IsSyntheticLegacyEmail("user_abc@legacy.local") {
|
||||
t.Fatal("expected synthetic")
|
||||
}
|
||||
if !IsSyntheticLegacyEmail(" User@Legacy.Local ") {
|
||||
t.Fatal("expected case-insensitive synthetic")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("real@example.com") {
|
||||
t.Fatal("real email must not be treated as synthetic")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("legacy.local@example.com") {
|
||||
t.Fatal("suffix-only match; local-part must not trigger")
|
||||
}
|
||||
if IsSyntheticLegacyEmail("") {
|
||||
t.Fatal("empty must not be synthetic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailsEqual(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !EmailsEqual("A@Example.COM", " a@example.com ") {
|
||||
t.Fatal("expected equal after normalize")
|
||||
}
|
||||
if EmailsEqual("a@example.com", "b@example.com") {
|
||||
t.Fatal("expected mismatch")
|
||||
}
|
||||
if !EmailsEqual("", "") {
|
||||
t.Fatal("empty emails should compare equal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
argonTime = 1
|
||||
argonMemory = 64 * 1024
|
||||
argonThreads = 4
|
||||
argonKeyLen = 32
|
||||
argonSaltLen = 16
|
||||
)
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < 8 {
|
||||
return "", ErrPasswordTooShort
|
||||
}
|
||||
salt := make([]byte, argonSaltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemory, argonTime, argonThreads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
func VerifyPassword(encoded, password string) (bool, error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false, errors.New("invalid password hash format")
|
||||
}
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, err
|
||||
}
|
||||
var memory, timeCost uint32
|
||||
var threads uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
|
||||
return false, err
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(want, got) == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// DefaultPasswordResetTTL is the self-serve reset link lifetime.
|
||||
const DefaultPasswordResetTTL = time.Hour
|
||||
|
||||
// PasswordResetIssue is returned once when a reset token is created (plaintext token for email only).
|
||||
type PasswordResetIssue struct {
|
||||
UserID uuid.UUID
|
||||
Email string
|
||||
Token string
|
||||
}
|
||||
|
||||
// IssuePasswordReset creates a durable hashed reset token for an active user with a deliverable email.
|
||||
// Unknown, inactive, and synthetic @legacy.local addresses return ErrUserNotFound / ErrSyntheticEmail
|
||||
// so callers can respond opaquely without enumeration.
|
||||
func (s *Service) IssuePasswordReset(ctx context.Context, email string, ttl time.Duration) (PasswordResetIssue, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if email == "" {
|
||||
return PasswordResetIssue{}, ErrEmailRequired
|
||||
}
|
||||
if IsSyntheticLegacyEmail(email) {
|
||||
return PasswordResetIssue{}, ErrSyntheticEmail
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultPasswordResetTTL
|
||||
}
|
||||
|
||||
var (
|
||||
userID uuid.UUID
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, is_active
|
||||
FROM users
|
||||
WHERE lower(email) = $1`, email).Scan(&userID, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if !isActive {
|
||||
return PasswordResetIssue{}, ErrUserNotFound
|
||||
}
|
||||
|
||||
token, err := RandomToken(24)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Invalidate prior unused tokens so only the latest link works.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND consumed_at IS NULL AND expires_at > now()`, userID); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`, userID, HashInviteToken(token), expiresAt); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return PasswordResetIssue{}, err
|
||||
}
|
||||
|
||||
return PasswordResetIssue{UserID: userID, Email: email, Token: token}, nil
|
||||
}
|
||||
|
||||
// ResetPasswordWithToken consumes a one-time reset token and sets a new password
|
||||
// regardless of must_set_password (dedicated reset path — not SetPassword / ForceSetPassword).
|
||||
func (s *Service) ResetPasswordWithToken(ctx context.Context, rawToken, password string) error {
|
||||
rawToken = strings.TrimSpace(rawToken)
|
||||
if rawToken == "" {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var (
|
||||
tokenID uuid.UUID
|
||||
userID uuid.UUID
|
||||
expiresAt time.Time
|
||||
consumedAt *time.Time
|
||||
)
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, user_id, expires_at, consumed_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token_hash = $1
|
||||
FOR UPDATE`, HashInviteToken(rawToken)).Scan(&tokenID, &userID, &expiresAt, &consumedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if consumedAt != nil || !expiresAt.After(time.Now().UTC()) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
var isActive bool
|
||||
err = tx.QueryRow(ctx, `SELECT is_active FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !isActive) {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
if isUndefinedColumn(err) {
|
||||
// Pre-042 DBs: still reset password; session revoke requires session_version migration.
|
||||
ct, err = tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, passwordHash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrTokenInvalid
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET consumed_at = now()
|
||||
WHERE id = $1`, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Expire any sibling unused tokens for this user.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE password_reset_tokens
|
||||
SET expires_at = least(expires_at, now())
|
||||
WHERE user_id = $1 AND id <> $2 AND consumed_at IS NULL AND expires_at > now()`,
|
||||
userID, tokenID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultPasswordResetTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
if DefaultPasswordResetTTL != time.Hour {
|
||||
t.Fatalf("DefaultPasswordResetTTL=%v want 1h", DefaultPasswordResetTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuePasswordResetRejectsSyntheticEmail(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Service{} // no Pool — synthetic check must return before any DB use
|
||||
for _, email := range []string{
|
||||
"user_abc@legacy.local",
|
||||
" User_ABC@Legacy.Local ",
|
||||
} {
|
||||
_, err := s.IssuePasswordReset(t.Context(), email, 0)
|
||||
if !errors.Is(err, ErrSyntheticEmail) {
|
||||
t.Fatalf("email=%q err=%v want ErrSyntheticEmail", email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPasswordRejectsShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := HashPassword("short")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for password shorter than 8 characters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPasswordAndVerifyRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
const password = "correct-horse-battery"
|
||||
encoded, err := HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(encoded, "$argon2id$") {
|
||||
t.Fatalf("unexpected encoding prefix: %q", encoded)
|
||||
}
|
||||
ok, err := VerifyPassword(encoded, password)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
ok, err = VerifyPassword(encoded, "wrong-password")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword wrong: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected wrong password to fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPasswordInvalidFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := VerifyPassword("not-a-hash", "anything12")
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid format error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomTokenLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
tok, err := RandomToken(24)
|
||||
if err != nil {
|
||||
t.Fatalf("RandomToken: %v", err)
|
||||
}
|
||||
if len(tok) != 48 {
|
||||
t.Fatalf("expected hex length 48, got %d (%q)", len(tok), tok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrMustSetPassword = errors.New("password_not_set")
|
||||
ErrInviteInvalid = errors.New("invite invalid or expired")
|
||||
ErrPasswordAlreadySet = errors.New("password already set")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
MustSetPassword bool `json:"must_set_password"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Company struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type RegisterInput struct {
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
CompanyName string
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
CompanyID uuid.UUID `json:"company_id"`
|
||||
Companies []Company `json:"companies"`
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(in.Email))
|
||||
if email == "" || in.Password == "" || strings.TrimSpace(in.CompanyName) == "" {
|
||||
return LoginResult{}, ErrRegisterFieldsRequired
|
||||
}
|
||||
hash, err := HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var existing uuid.UUID
|
||||
err = tx.QueryRow(ctx, `SELECT id FROM users WHERE email = $1`, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return LoginResult{}, ErrUserExists
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
var userID uuid.UUID
|
||||
var name *string
|
||||
if strings.TrimSpace(in.Name) != "" {
|
||||
n := strings.TrimSpace(in.Name)
|
||||
name = &n
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, must_set_password)
|
||||
VALUES ($1, $2, $3, false)
|
||||
RETURNING id`, email, name, hash).Scan(&userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
var companyID uuid.UUID
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO companies (name) VALUES ($1) RETURNING id`, strings.TrimSpace(in.CompanyName)).Scan(&companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, 'admin', 'active')`, companyID, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO company_settings (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id) VALUES ($1)
|
||||
ON CONFLICT DO NOTHING`, companyID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{
|
||||
User: user,
|
||||
CompanyID: companyID,
|
||||
Companies: []Company{{ID: companyID, Name: strings.TrimSpace(in.CompanyName)}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, email, password string) (LoginResult, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
var (
|
||||
user User
|
||||
hash *string
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users WHERE email = $1`, email).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.StaffRole, &user.IsActive,
|
||||
)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, password_hash, must_set_password, is_platform_admin, is_active
|
||||
FROM users WHERE email = $1`, email).Scan(
|
||||
&user.ID, &user.Email, &user.Name, &hash, &user.MustSetPassword, &user.IsPlatformAdmin, &user.IsActive,
|
||||
)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if !user.IsActive {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
// Migrated / invite-pending accounts have no usable password until accept-invite or set-password.
|
||||
if user.MustSetPassword || hash == nil || *hash == "" {
|
||||
if user.MustSetPassword {
|
||||
return LoginResult{}, ErrMustSetPassword
|
||||
}
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
ok, err := VerifyPassword(*hash, password)
|
||||
if err != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
companies, err := s.ListUserCompanies(ctx, user.ID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
var companyID uuid.UUID
|
||||
if len(companies) > 0 {
|
||||
companyID = companies[0].ID
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `UPDATE users SET last_login_at = now(), updated_at = now() WHERE id = $1`, user.ID)
|
||||
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
|
||||
}
|
||||
|
||||
func (s *Service) AcceptInvite(ctx context.Context, token, password, name string) (LoginResult, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
var (
|
||||
inviteID, companyID uuid.UUID
|
||||
email, role string
|
||||
expiresAt time.Time
|
||||
acceptedAt *time.Time
|
||||
)
|
||||
// Prefer hashed lookup (at-rest); also accept legacy plaintext rows until they expire.
|
||||
tokenHash := HashInviteToken(token)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, email, role, expires_at, accepted_at
|
||||
FROM invites
|
||||
WHERE token = $1 OR token = $2
|
||||
ORDER BY CASE WHEN token = $1 THEN 0 ELSE 1 END
|
||||
LIMIT 1`, tokenHash, token).Scan(
|
||||
&inviteID, &companyID, &email, &role, &expiresAt, &acceptedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) || (acceptedAt != nil) || time.Now().After(expiresAt) {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var userID uuid.UUID
|
||||
var existingHash string
|
||||
var mustSet bool
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, password_hash, must_set_password FROM users WHERE email = $1`,
|
||||
strings.ToLower(email)).Scan(&userID, &existingHash, &mustSet)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
var n *string
|
||||
if strings.TrimSpace(name) != "" {
|
||||
nn := strings.TrimSpace(name)
|
||||
n = &nn
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, must_set_password)
|
||||
VALUES ($1, $2, $3, false) RETURNING id`, strings.ToLower(email), n, hash).Scan(&userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else if err != nil {
|
||||
return LoginResult{}, err
|
||||
} else if mustSet {
|
||||
// Migration / first-password invites may set a password once.
|
||||
hash, herr := HashPassword(password)
|
||||
if herr != nil {
|
||||
return LoginResult{}, herr
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
} else {
|
||||
// Existing accounts keep their password; invitee must prove ownership.
|
||||
ok, verr := VerifyPassword(existingHash, password)
|
||||
if verr != nil || !ok {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO memberships (company_id, user_id, role, status)
|
||||
VALUES ($1, $2, $3, 'active')
|
||||
ON CONFLICT (company_id, user_id) DO UPDATE
|
||||
SET role = CASE
|
||||
WHEN memberships.role = 'admin' THEN memberships.role
|
||||
ELSE EXCLUDED.role
|
||||
END,
|
||||
status = 'active', updated_at = now()`,
|
||||
companyID, userID, role)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
ct, err := tx.Exec(ctx, `
|
||||
UPDATE invites SET accepted_at = now()
|
||||
WHERE id = $1 AND accepted_at IS NULL`, inviteID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return LoginResult{}, ErrInviteInvalid
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
companies, err := s.ListUserCompanies(ctx, userID)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
return LoginResult{User: user, CompanyID: companyID, Companies: companies}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetPassword(ctx context.Context, userID uuid.UUID, password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only users flagged must_set_password may set via token/session bootstrap.
|
||||
// This also makes HMAC set-password tokens single-use after success.
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
if isUndefinedColumn(err) {
|
||||
ct, err = s.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND must_set_password = true`, userID, hash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
var exists bool
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
|
||||
if exists {
|
||||
return ErrPasswordAlreadySet
|
||||
}
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword verifies the current password then sets a new one (in-app Settings).
|
||||
// Bumps session_version so other sessions are revoked; callers must re-stamp the cookie.
|
||||
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error {
|
||||
var (
|
||||
hash string
|
||||
mustSetPassword bool
|
||||
isActive bool
|
||||
)
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT password_hash, must_set_password, is_active
|
||||
FROM users WHERE id = $1`, userID).Scan(&hash, &mustSetPassword, &isActive)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isActive {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
if mustSetPassword {
|
||||
return ErrMustSetPassword
|
||||
}
|
||||
ok, err := VerifyPassword(hash, currentPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
newHash, err := HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2,
|
||||
must_set_password = false,
|
||||
session_version = session_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
|
||||
if isUndefinedColumn(err) {
|
||||
ct, err = s.Pool.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true AND must_set_password = false`, userID, newHash)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceSetPassword sets a password regardless of must_set_password (local/admin bootstrap).
|
||||
func (s *Service) ForceSetPassword(ctx context.Context, userID uuid.UUID, password string) error {
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
UPDATE users SET password_hash = $2, must_set_password = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true`, userID, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
var u User
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, staff_role, is_active
|
||||
FROM users WHERE id = $1`, id).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive,
|
||||
)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT id, email, name, must_set_password, is_platform_admin, is_active
|
||||
FROM users WHERE id = $1`, id).Scan(
|
||||
&u.ID, &u.Email, &u.Name, &u.MustSetPassword, &u.IsPlatformAdmin, &u.IsActive,
|
||||
)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Service) ListUserCompanies(ctx context.Context, userID uuid.UUID) ([]Company, error) {
|
||||
// Prefer Platform Demo sandbox when present, then richest tenant (products/feeds).
|
||||
// A1 Slovenija wins remaining ties; accept old Local Demo Co rename as A1 alias.
|
||||
const a1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, c.name
|
||||
FROM memberships m
|
||||
JOIN companies c ON c.id = m.company_id
|
||||
WHERE m.user_id = $1 AND m.status = 'active'
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN lower(c.name) IN ('platform demo', 'demo') THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
(SELECT COUNT(*) FROM processed_products p WHERE p.company_id = c.id) DESC,
|
||||
(SELECT COUNT(*) FROM input_feeds f WHERE f.company_id = c.id) DESC,
|
||||
CASE
|
||||
WHEN lower(c.name) = 'a1 slovenija' THEN 0
|
||||
WHEN lower(c.name) = 'local demo co' THEN 0
|
||||
WHEN lower(COALESCE(c.legacy_company_id, '')) = lower($2) THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
c.name`, userID, a1LegacyCompanyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Company
|
||||
for rows.Next() {
|
||||
var c Company
|
||||
if err := rows.Scan(&c.ID, &c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) EnsureMembership(ctx context.Context, userID, companyID uuid.UUID) (Membership, error) {
|
||||
var m Membership
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, user_id, role, status
|
||||
FROM memberships
|
||||
WHERE user_id = $1 AND company_id = $2 AND status = 'active'`,
|
||||
userID, companyID).Scan(&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Membership{}, ErrNotCompanyMember
|
||||
}
|
||||
return m, err
|
||||
}
|
||||
|
||||
func RandomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/pgxstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func NewSessionManager(pool *pgxpool.Pool, cookieName string, secure bool, idleHours int) *scs.SessionManager {
|
||||
sm := scs.New()
|
||||
sm.Store = pgxstore.New(pool)
|
||||
sm.Lifetime = 7 * 24 * time.Hour
|
||||
if idleHours <= 0 {
|
||||
idleHours = 24
|
||||
}
|
||||
sm.IdleTimeout = time.Duration(idleHours) * time.Hour
|
||||
sm.Cookie.Name = cookieName
|
||||
sm.Cookie.HttpOnly = true
|
||||
sm.Cookie.Secure = secure
|
||||
sm.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sm.Cookie.Path = "/"
|
||||
return sm
|
||||
}
|
||||
|
||||
const (
|
||||
SessionUserIDKey = "user_id"
|
||||
SessionCompanyIDKey = "company_id"
|
||||
SessionImpersonatorIDKey = "impersonator_id" // non-prod user switch: original admin/demo
|
||||
SessionVersionKey = "session_version" // must match users.session_version
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewSessionManagerCookieFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sm := NewSessionManager(nil, "descrybe_session", true, 12)
|
||||
if sm.Cookie.Name != "descrybe_session" {
|
||||
t.Fatalf("Name = %q", sm.Cookie.Name)
|
||||
}
|
||||
if !sm.Cookie.HttpOnly {
|
||||
t.Fatal("session cookie must be HttpOnly")
|
||||
}
|
||||
if !sm.Cookie.Secure {
|
||||
t.Fatal("secure=true must set Secure")
|
||||
}
|
||||
if sm.Cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("SameSite = %v, want Lax", sm.Cookie.SameSite)
|
||||
}
|
||||
if sm.Cookie.Path != "/" {
|
||||
t.Fatalf("Path = %q, want /", sm.Cookie.Path)
|
||||
}
|
||||
if sm.IdleTimeout != 12*time.Hour {
|
||||
t.Fatalf("IdleTimeout = %v, want 12h", sm.IdleTimeout)
|
||||
}
|
||||
if sm.Lifetime != 7*24*time.Hour {
|
||||
t.Fatalf("Lifetime = %v, want 7d", sm.Lifetime)
|
||||
}
|
||||
|
||||
insecure := NewSessionManager(nil, "descrybe_session", false, 0)
|
||||
if insecure.Cookie.Secure {
|
||||
t.Fatal("secure=false must not set Secure")
|
||||
}
|
||||
if !insecure.Cookie.HttpOnly {
|
||||
t.Fatal("session cookie must remain HttpOnly")
|
||||
}
|
||||
if insecure.IdleTimeout != 24*time.Hour {
|
||||
t.Fatalf("default IdleTimeout = %v, want 24h", insecure.IdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// UserSessionState is the cookie-session gate (active flag + version for revoke-on-reset).
|
||||
type UserSessionState struct {
|
||||
Active bool
|
||||
Version int
|
||||
}
|
||||
|
||||
// UserSessionState loads is_active and session_version for RequireSession.
|
||||
// When session_version is not migrated yet, Version defaults to 0 (pre-hardening sessions keep working).
|
||||
func (s *Service) UserSessionState(ctx context.Context, userID uuid.UUID) (UserSessionState, error) {
|
||||
var st UserSessionState
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_active, session_version
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&st.Active, &st.Version)
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT is_active
|
||||
FROM users
|
||||
WHERE id = $1`, userID).Scan(&st.Active)
|
||||
st.Version = 0
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return UserSessionState{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return UserSessionState{}, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestResetPasswordWithTokenBumpsSessionVersion(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var ready bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
|
||||
}
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = 'password_reset_tokens'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("password_reset_tokens missing — run goose up for 041_password_reset_tokens")
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "session-ver-" + userID.String()[:8] + "@example.test"
|
||||
hash, err := HashPassword("OldPassword123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
|
||||
VALUES ($1, $2, $3, $4, false, false, true, 3)`,
|
||||
userID, email, "Session Ver", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx := t.Context()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM password_reset_tokens WHERE user_id = $1`, userID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
issue, err := svc.IssuePasswordReset(ctx, email, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssuePasswordReset: %v", err)
|
||||
}
|
||||
if err := svc.ResetPasswordWithToken(ctx, issue.Token, "NewPassword456!"); err != nil {
|
||||
t.Fatalf("ResetPasswordWithToken: %v", err)
|
||||
}
|
||||
|
||||
st, err := svc.UserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserSessionState: %v", err)
|
||||
}
|
||||
if !st.Active {
|
||||
t.Fatal("expected active user")
|
||||
}
|
||||
if st.Version != 4 {
|
||||
t.Fatalf("session_version=%d want 4 (bumped from 3)", st.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordBumpsSessionVersion(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
var ready bool
|
||||
if err := pg.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'session_version'
|
||||
)`).Scan(&ready); err != nil || !ready {
|
||||
t.Skip("users.session_version missing — run goose up for 042_user_session_version")
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "change-pw-" + userID.String()[:8] + "@example.test"
|
||||
const oldPassword = "OldPassword123!"
|
||||
hash, err := HashPassword(oldPassword)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active, session_version)
|
||||
VALUES ($1, $2, $3, $4, false, false, true, 2)`,
|
||||
userID, email, "Change PW", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, "wrong-password", "NewPassword456!"); err != ErrInvalidCredentials {
|
||||
t.Fatalf("wrong current: err=%v want ErrInvalidCredentials", err)
|
||||
}
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, oldPassword, "short"); err != ErrPasswordTooShort {
|
||||
t.Fatalf("short password: err=%v want ErrPasswordTooShort", err)
|
||||
}
|
||||
|
||||
if err := svc.ChangePassword(ctx, userID, oldPassword, "NewPassword456!"); err != nil {
|
||||
t.Fatalf("ChangePassword: %v", err)
|
||||
}
|
||||
|
||||
st, err := svc.UserSessionState(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("UserSessionState: %v", err)
|
||||
}
|
||||
if st.Version != 3 {
|
||||
t.Fatalf("session_version=%d want 3 (bumped from 2)", st.Version)
|
||||
}
|
||||
|
||||
var stored string
|
||||
if err := pg.QueryRow(ctx, `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&stored); err != nil {
|
||||
t.Fatalf("load hash: %v", err)
|
||||
}
|
||||
ok, err := VerifyPassword(stored, "NewPassword456!")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("new password verify ok=%v err=%v", ok, err)
|
||||
}
|
||||
ok, err = VerifyPassword(stored, oldPassword)
|
||||
if err != nil || ok {
|
||||
t.Fatal("old password should no longer verify")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPasswordRejectsWhenAlreadySet(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := t.Context()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
t.Cleanup(pg.Close)
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
userID := uuid.New()
|
||||
email := "set-pw-" + userID.String()[:8] + "@example.test"
|
||||
hash, err := HashPassword("AlreadySet123!")
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO users (id, email, name, password_hash, must_set_password, is_platform_admin, is_active)
|
||||
VALUES ($1, $2, $3, $4, false, false, true)`,
|
||||
userID, email, "Set PW", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(t.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||
})
|
||||
|
||||
if err := svc.SetPassword(ctx, userID, "AnotherPass123!"); err != ErrPasswordAlreadySet {
|
||||
t.Fatalf("SetPassword: err=%v want ErrPasswordAlreadySet", err)
|
||||
}
|
||||
if err := svc.ChangePassword(ctx, userID, "AlreadySet123!", "short"); err != ErrPasswordTooShort {
|
||||
// ensure ChangePassword path still works for eligible users after SetPassword rejection
|
||||
t.Fatalf("ChangePassword short: err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Platform staff roles (users.staff_role). Orthogonal to company membership roles.
|
||||
const (
|
||||
StaffRoleAdmin = "admin"
|
||||
StaffRoleDeveloper = "developer"
|
||||
StaffRoleSupportStaff = "support_staff"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidStaffRole = errors.New("invalid staff_role")
|
||||
ErrStaffUserNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// StaffAccess is the resolved capability set for a platform staff user.
|
||||
type StaffAccess struct {
|
||||
Role string `json:"staff_role,omitempty"`
|
||||
FullAdmin bool `json:"full_admin"`
|
||||
SupportDesk bool `json:"support_desk"`
|
||||
IsSupportOnly bool `json:"is_support_only"`
|
||||
}
|
||||
|
||||
// NormalizeStaffRole returns a known staff role or empty string.
|
||||
func NormalizeStaffRole(raw string) (string, error) {
|
||||
role := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch role {
|
||||
case "", StaffRoleAdmin, StaffRoleDeveloper, StaffRoleSupportStaff:
|
||||
return role, nil
|
||||
default:
|
||||
return "", ErrInvalidStaffRole
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveStaffRole returns the effective staff role per contract 04:
|
||||
// staff_role if set; else admin when is_platform_admin; else empty.
|
||||
func ResolveStaffRole(isPlatformAdmin bool, staffRole string) string {
|
||||
role, _ := NormalizeStaffRole(staffRole)
|
||||
if role != "" {
|
||||
return role
|
||||
}
|
||||
if isPlatformAdmin {
|
||||
return StaffRoleAdmin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveStaffAccess maps DB flags to capabilities.
|
||||
//
|
||||
// Rules (fail closed):
|
||||
// - staff_role=support_staff → support desk only (never full admin), even if is_platform_admin.
|
||||
// - staff_role=admin|developer → full admin + support desk.
|
||||
// - staff_role empty + is_platform_admin → legacy full admin (backward compatible).
|
||||
// - otherwise → no staff access.
|
||||
func ResolveStaffAccess(isPlatformAdmin bool, staffRole string) StaffAccess {
|
||||
role := ResolveStaffRole(isPlatformAdmin, staffRole)
|
||||
switch role {
|
||||
case StaffRoleSupportStaff:
|
||||
return StaffAccess{
|
||||
Role: StaffRoleSupportStaff,
|
||||
FullAdmin: false,
|
||||
SupportDesk: true,
|
||||
IsSupportOnly: true,
|
||||
}
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return StaffAccess{
|
||||
Role: role,
|
||||
FullAdmin: true,
|
||||
SupportDesk: true,
|
||||
}
|
||||
default:
|
||||
return StaffAccess{}
|
||||
}
|
||||
}
|
||||
|
||||
// StaffCapabilities lists platform capability keys for a resolved staff role.
|
||||
func StaffCapabilities(role string) []string {
|
||||
switch role {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
"staff.users.read",
|
||||
"staff.users.write",
|
||||
"staff.analytics",
|
||||
"staff.billing",
|
||||
"staff.plans_features",
|
||||
"staff.feature_gates",
|
||||
"staff.settings",
|
||||
"staff.jobs_stuck",
|
||||
"staff.impersonate",
|
||||
"staff.dev_password",
|
||||
}
|
||||
case StaffRoleSupportStaff:
|
||||
return []string{
|
||||
"staff.admin_shell",
|
||||
"staff.support.queue",
|
||||
"staff.support.reply",
|
||||
"staff.support.assign",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetStaffAccess loads is_platform_admin + staff_role for an active user.
|
||||
// Missing staff_role column (pre-migration) falls back to boolean-only admin.
|
||||
func (s *Service) GetStaffAccess(ctx context.Context, userID uuid.UUID) (StaffAccess, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return StaffAccess{}, errors.New("auth service unavailable")
|
||||
}
|
||||
var isAdmin bool
|
||||
var staffRole *string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin, staff_role
|
||||
FROM users
|
||||
WHERE id = $1 AND is_active = true`, userID,
|
||||
).Scan(&isAdmin, &staffRole)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffAccess{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
ok, err2 := s.platformAdminFlag(ctx, userID)
|
||||
if err2 != nil {
|
||||
return StaffAccess{}, err2
|
||||
}
|
||||
return ResolveStaffAccess(ok, ""), nil
|
||||
}
|
||||
return StaffAccess{}, err
|
||||
}
|
||||
role := ""
|
||||
if staffRole != nil {
|
||||
role = *staffRole
|
||||
}
|
||||
return ResolveStaffAccess(isAdmin, role), nil
|
||||
}
|
||||
|
||||
// platformAdminFlag reads users.is_platform_admin without staff_role resolution.
|
||||
func (s *Service) platformAdminFlag(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
var ok bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT is_platform_admin FROM users WHERE id = $1 AND is_active = true`, userID).Scan(&ok)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// StaffUser is a platform staff directory row.
|
||||
type StaffUser struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
IsPlatformAdmin bool `json:"is_platform_admin"`
|
||||
StaffRole *string `json:"staff_role,omitempty"`
|
||||
ResolvedRole string `json:"resolved_role"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ListStaffUsers returns active users with any platform staff access.
|
||||
func (s *Service) ListStaffUsers(ctx context.Context, limit, offset int) ([]StaffUser, error) {
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, email, name, is_platform_admin, staff_role, is_active
|
||||
FROM users
|
||||
WHERE is_active = true
|
||||
AND (is_platform_admin = true OR staff_role IS NOT NULL)
|
||||
ORDER BY coalesce(staff_role, ''), email
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]StaffUser, 0)
|
||||
for rows.Next() {
|
||||
var u StaffUser
|
||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &u.StaffRole, &u.IsActive); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := ""
|
||||
if u.StaffRole != nil {
|
||||
stored = *u.StaffRole
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, stored)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetStaffRole assigns or clears a platform staff role.
|
||||
// Non-empty role sets is_platform_admin=true (contract invariant).
|
||||
// Empty role clears staff_role and is_platform_admin.
|
||||
func (s *Service) SetStaffRole(ctx context.Context, userID uuid.UUID, staffRole string) (StaffUser, error) {
|
||||
role, err := NormalizeStaffRole(staffRole)
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
var (
|
||||
u StaffUser
|
||||
stored *string
|
||||
)
|
||||
if role == "" {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = NULL, is_platform_admin = false, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
} else {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
UPDATE users
|
||||
SET staff_role = $2, is_platform_admin = true, updated_at = now()
|
||||
WHERE id = $1 AND is_active = true
|
||||
RETURNING id, email, name, is_platform_admin, staff_role, is_active`, userID, role,
|
||||
).Scan(&u.ID, &u.Email, &u.Name, &u.IsPlatformAdmin, &stored, &u.IsActive)
|
||||
}
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return StaffUser{}, ErrStaffUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return StaffUser{}, err
|
||||
}
|
||||
u.StaffRole = stored
|
||||
storedRole := ""
|
||||
if stored != nil {
|
||||
storedRole = *stored
|
||||
}
|
||||
u.ResolvedRole = ResolveStaffRole(u.IsPlatformAdmin, storedRole)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// IsAssignableSupportStaff reports whether userID may be set as a ticket assignee.
|
||||
func (s *Service) IsAssignableSupportStaff(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||
access, err := s.GetStaffAccess(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return access.SupportDesk, nil
|
||||
}
|
||||
|
||||
func isUndefinedColumn(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
return pgErr.Code == "42703"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaffRoleFromPlatformAdmin maps the legacy boolean gate onto staff roles.
|
||||
// Until a dedicated staff_role column exists: platform admin → admin.
|
||||
func StaffRoleFromPlatformAdmin(isPlatformAdmin bool) string {
|
||||
if isPlatformAdmin {
|
||||
return StaffRoleAdmin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// supportStaffFeatureOff keys denied for support_staff (03-roles-matrix.json).
|
||||
var supportStaffFeatureOff = map[string]struct{}{
|
||||
"billing.checkout": {},
|
||||
"billing.customer_portal": {},
|
||||
"billing.quick_upgrade": {},
|
||||
"capability.api_access": {},
|
||||
"capability.brand_ai_apply": {},
|
||||
"capability.byok": {},
|
||||
"capability.campaign_ai": {},
|
||||
"capability.email_live_send": {},
|
||||
"capability.seo_ai_rewrite": {},
|
||||
"catalog.structured_descriptions": {},
|
||||
"catalog.vector_categories": {},
|
||||
"dashboard.store_reconnect": {},
|
||||
"integrations.ai": {},
|
||||
"integrations.ai.byok": {},
|
||||
"integrations.email": {},
|
||||
"integrations.email.blast": {},
|
||||
"integrations.email.test": {},
|
||||
"marketing.brand_ai_apply": {},
|
||||
"marketing.brand_kit": {},
|
||||
"marketing.campaigns": {},
|
||||
"marketing.campaigns.create": {},
|
||||
"marketing.campaigns.generate_ai": {},
|
||||
"marketing.campaigns.send": {},
|
||||
"marketing.content_calendar": {},
|
||||
"marketing.reviews": {},
|
||||
"marketing.seo": {},
|
||||
"marketing.seo.ai_rewrite": {},
|
||||
"marketing.seo.template_fill": {},
|
||||
"settings.api_keys": {},
|
||||
"settings.team_invite": {},
|
||||
"stores.hub": {},
|
||||
"stores.shopify": {},
|
||||
"stores.shopify.connection": {},
|
||||
"stores.shopify.orders": {},
|
||||
"stores.shopify.settings": {},
|
||||
"stores.woocommerce": {},
|
||||
"stores.woocommerce.attributes": {},
|
||||
"stores.woocommerce.categories": {},
|
||||
"stores.woocommerce.connection": {},
|
||||
"stores.woocommerce.orders": {},
|
||||
"stores.woocommerce.reviews": {},
|
||||
"stores.woocommerce.settings": {},
|
||||
}
|
||||
|
||||
// DefaultStaffRoleAllows reports the dashboard feature ceiling for a staff role
|
||||
// when acting in a tenant context (compose with plan_allows at resolve time).
|
||||
// admin / developer → all keys ON; support_staff → limited set; unknown → false.
|
||||
func DefaultStaffRoleAllows(role string, featureKey string) bool {
|
||||
featureKey = strings.TrimSpace(featureKey)
|
||||
normalized, _ := NormalizeStaffRole(role)
|
||||
switch normalized {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return true
|
||||
case StaffRoleSupportStaff:
|
||||
_, denied := supportStaffFeatureOff[featureKey]
|
||||
return !denied
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// StaffRoleAllowsAdminRoute is the platform console ceiling (not feature keys).
|
||||
// Per contract 04: support_staff → /admin/support only; admin|developer → all.
|
||||
func StaffRoleAllowsAdminRoute(role string, route string) bool {
|
||||
route = strings.ToLower(strings.TrimSpace(route))
|
||||
normalized, _ := NormalizeStaffRole(role)
|
||||
switch normalized {
|
||||
case StaffRoleAdmin, StaffRoleDeveloper:
|
||||
return true
|
||||
case StaffRoleSupportStaff:
|
||||
return strings.HasPrefix(route, "/admin/support")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultStaffRoleAllows(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !DefaultStaffRoleAllows(StaffRoleAdmin, "billing.checkout") {
|
||||
t.Fatal("admin allows all")
|
||||
}
|
||||
if !DefaultStaffRoleAllows(StaffRoleDeveloper, "catalog.vector_categories") {
|
||||
t.Fatal("developer allows debug catalog")
|
||||
}
|
||||
if DefaultStaffRoleAllows(StaffRoleSupportStaff, "billing.checkout") {
|
||||
t.Fatal("support_staff denies billing checkout")
|
||||
}
|
||||
if !DefaultStaffRoleAllows(StaffRoleSupportStaff, "support.center") {
|
||||
t.Fatal("support_staff allows support.center")
|
||||
}
|
||||
if DefaultStaffRoleAllows("", "dashboard.overview") {
|
||||
t.Fatal("unknown role denies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffRoleAllowsAdminRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleAdmin, "/admin/billing") {
|
||||
t.Fatal("admin billing")
|
||||
}
|
||||
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
|
||||
t.Fatal("support_staff no billing")
|
||||
}
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
|
||||
t.Fatal("support_staff support queue")
|
||||
}
|
||||
if StaffRoleFromPlatformAdmin(true) != StaffRoleAdmin {
|
||||
t.Fatal("platform admin maps to admin")
|
||||
}
|
||||
if StaffRoleFromPlatformAdmin(false) != "" {
|
||||
t.Fatal("non-admin maps empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveStaffAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
admin bool
|
||||
role string
|
||||
wantRole string
|
||||
wantFull bool
|
||||
wantDesk bool
|
||||
wantOnly bool
|
||||
}{
|
||||
{name: "legacy_platform_admin", admin: true, role: "", wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
|
||||
{name: "plain_user", admin: false, role: "", wantFull: false, wantDesk: false},
|
||||
{name: "support_staff", admin: false, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
|
||||
{name: "support_staff_with_admin_flag", admin: true, role: StaffRoleSupportStaff, wantRole: StaffRoleSupportStaff, wantFull: false, wantDesk: true, wantOnly: true},
|
||||
{name: "admin_role", admin: true, role: StaffRoleAdmin, wantRole: StaffRoleAdmin, wantFull: true, wantDesk: true},
|
||||
{name: "developer_role", admin: false, role: StaffRoleDeveloper, wantRole: StaffRoleDeveloper, wantFull: true, wantDesk: true},
|
||||
{name: "unknown_role_ignored", admin: false, role: "superuser", wantFull: false, wantDesk: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := ResolveStaffAccess(tc.admin, tc.role)
|
||||
if got.Role != tc.wantRole {
|
||||
t.Fatalf("role = %q, want %q", got.Role, tc.wantRole)
|
||||
}
|
||||
if got.FullAdmin != tc.wantFull || got.SupportDesk != tc.wantDesk || got.IsSupportOnly != tc.wantOnly {
|
||||
t.Fatalf("got full=%v desk=%v only=%v want full=%v desk=%v only=%v",
|
||||
got.FullAdmin, got.SupportDesk, got.IsSupportOnly, tc.wantFull, tc.wantDesk, tc.wantOnly)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffCapabilities(t *testing.T) {
|
||||
t.Parallel()
|
||||
adminCaps := StaffCapabilities(StaffRoleAdmin)
|
||||
if len(adminCaps) < 10 {
|
||||
t.Fatalf("admin caps too small: %v", adminCaps)
|
||||
}
|
||||
supportCaps := StaffCapabilities(StaffRoleSupportStaff)
|
||||
if len(supportCaps) != 4 {
|
||||
t.Fatalf("support caps = %v", supportCaps)
|
||||
}
|
||||
for _, c := range supportCaps {
|
||||
if strings.Contains(c, "billing") || strings.Contains(c, "settings") {
|
||||
t.Fatalf("support must not get %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaffRoleAllowsAdminRouteContract(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/support") {
|
||||
t.Fatal("support_staff should access /admin/support")
|
||||
}
|
||||
if StaffRoleAllowsAdminRoute(StaffRoleSupportStaff, "/admin/billing") {
|
||||
t.Fatal("support_staff must not access billing")
|
||||
}
|
||||
if !StaffRoleAllowsAdminRoute(StaffRoleDeveloper, "/admin/settings") {
|
||||
t.Fatal("developer should access settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStaffRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := NormalizeStaffRole("nope"); err == nil {
|
||||
t.Fatal("expected error for invalid role")
|
||||
}
|
||||
got, err := NormalizeStaffRole(" Support_Staff ")
|
||||
if err != nil || got != StaffRoleSupportStaff {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrTokenInvalid = errors.New("token invalid or expired")
|
||||
|
||||
// IssueSetPasswordToken creates a signed, time-limited token (no DB row).
|
||||
// secret must come from env (TOKEN_SIGNING_SECRET); never commit secrets.
|
||||
func IssueSetPasswordToken(secret string, userID uuid.UUID, ttl time.Duration) (string, error) {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return "", errors.New("token signing secret not configured")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 72 * time.Hour
|
||||
}
|
||||
exp := time.Now().Add(ttl).Unix()
|
||||
nonce, err := RandomToken(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := fmt.Sprintf("%s.%d.%s", userID.String(), exp, nonce)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
raw := payload + "." + sig
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(raw)), nil
|
||||
}
|
||||
|
||||
func ParseSetPasswordToken(secret, token string) (uuid.UUID, error) {
|
||||
if strings.TrimSpace(secret) == "" || strings.TrimSpace(token) == "" {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
parts := strings.Split(string(raw), ".")
|
||||
if len(parts) != 4 {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
userID, err := uuid.Parse(parts[0])
|
||||
if err != nil {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
exp, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || time.Now().Unix() > exp {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
payload := parts[0] + "." + parts[1] + "." + parts[2]
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[3])) {
|
||||
return uuid.Nil, ErrTokenInvalid
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestIssueAndParseSetPasswordToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
const secret = "test-signing-secret-not-for-prod"
|
||||
uid := uuid.MustParse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
token, err := IssueSetPasswordToken(secret, uid, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueSetPasswordToken: %v", err)
|
||||
}
|
||||
got, err := ParseSetPasswordToken(secret, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSetPasswordToken: %v", err)
|
||||
}
|
||||
if got != uid {
|
||||
t.Fatalf("user id = %s, want %s", got, uid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSetPasswordTokenRejectsWrongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.New()
|
||||
token, err := IssueSetPasswordToken("secret-a", uid, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueSetPasswordToken: %v", err)
|
||||
}
|
||||
if _, err := ParseSetPasswordToken("secret-b", token); err == nil {
|
||||
t.Fatal("expected invalid token for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSetPasswordTokenRejectsExpired(t *testing.T) {
|
||||
t.Parallel()
|
||||
uid := uuid.New()
|
||||
const secret = "secret"
|
||||
// Build an already-expired signed token (IssueSetPasswordToken coerces ttl<=0 to 72h).
|
||||
exp := time.Now().Add(-time.Hour).Unix()
|
||||
nonce := "deadbeefdeadbeef"
|
||||
payload := uid.String() + "." + strconv.FormatInt(exp, 10) + "." + nonce
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
token := base64.RawURLEncoding.EncodeToString([]byte(payload + "." + sig))
|
||||
if _, err := ParseSetPasswordToken(secret, token); err == nil {
|
||||
t.Fatal("expected expired token to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueSetPasswordTokenRequiresSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := IssueSetPasswordToken("", uuid.New(), time.Hour); err == nil {
|
||||
t.Fatal("expected error when secret is empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCapabilitiesResponseETagStableAndSensitive(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := Capabilities{
|
||||
PlanID: 3,
|
||||
PlanName: "Growth",
|
||||
HasActivePlan: true,
|
||||
FeatureETag: featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": false}),
|
||||
Entitlements: Entitlements{RemainingCredits: 100},
|
||||
}
|
||||
a := CapabilitiesResponseETag(base)
|
||||
b := CapabilitiesResponseETag(base)
|
||||
if a == "" || a[0] != '"' || a[len(a)-1] != '"' {
|
||||
t.Fatalf("etag must be quoted strong form, got %q", a)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("etag unstable: %q vs %q", a, b)
|
||||
}
|
||||
|
||||
creditChanged := base
|
||||
creditChanged.Entitlements.RemainingCredits = 99
|
||||
if CapabilitiesResponseETag(creditChanged) == a {
|
||||
t.Fatal("etag must change when remaining credits change")
|
||||
}
|
||||
|
||||
featChanged := base
|
||||
featChanged.FeatureETag = featureETag(map[string]bool{"catalog.products": true, "settings.api_keys": true})
|
||||
if CapabilitiesResponseETag(featChanged) == a {
|
||||
t.Fatal("etag must change when feature map changes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureETagIgnoresDisabledKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := featureETag(map[string]bool{"a": true, "b": false})
|
||||
b := featureETag(map[string]bool{"a": true})
|
||||
if a != b {
|
||||
t.Fatalf("disabled keys should not affect feature etag: %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package billing
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrPlanNameRequired = errors.New("name required")
|
||||
ErrPlanNotFound = errors.New("plan not found")
|
||||
ErrAmountRequired = errors.New("amount required")
|
||||
ErrStripeNoCustomer = errors.New("no stripe customer for this company — complete a checkout first")
|
||||
)
|
||||
|
||||
// ClientError reports whether err is a known client-facing billing/Stripe error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrPlanNameRequired),
|
||||
errors.Is(err, ErrPlanNotFound),
|
||||
errors.Is(err, ErrAmountRequired),
|
||||
errors.Is(err, ErrStripeNotConfigured),
|
||||
errors.Is(err, ErrStripePlanUnsupported),
|
||||
errors.Is(err, ErrStripePriceMissing),
|
||||
errors.Is(err, ErrStripeNoCustomer),
|
||||
errors.Is(err, ErrInsufficientCredits),
|
||||
errors.Is(err, ErrProductLimitExceeded),
|
||||
errors.Is(err, ErrAIRequiresUpgrade),
|
||||
errors.Is(err, ErrEPRELRequiresUpgrade),
|
||||
errors.Is(err, ErrUnknownFeatureKey),
|
||||
errors.Is(err, ErrUnknownFeatureSection),
|
||||
errors.Is(err, ErrInvalidFeatureGates),
|
||||
errors.Is(err, ErrFeatureDisabled):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Concurrent ConsumeCredits on one company must serialize on credit_balances and
|
||||
// never overspend the wallet.
|
||||
func TestConsumeCreditsConcurrentNoOverspend(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-contention")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
// Paid plan keeps CanUseAI true at empty wallet so flat (0-token) debits still
|
||||
// hit the atomic UPDATE and return ErrInsufficientCredits (not a Free no-op).
|
||||
var planID int64
|
||||
err = pg.QueryRow(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, term)
|
||||
VALUES ($1, $2, $3, 'monthly')
|
||||
RETURNING id`, "consume-contention-"+companyID.String()[:8], "integration", 100).Scan(&planID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
|
||||
})
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
|
||||
VALUES ($1, $2, true, now() - interval '1 day', now() + interval '30 days')`, companyID, planID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const wallet = 20
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
||||
VALUES ($1, $2, 0, now())`, companyID, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
|
||||
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
_ = svc.EnsureDefaultCosts(ctx)
|
||||
|
||||
const workers = 40
|
||||
var wg sync.WaitGroup
|
||||
var okCount atomic.Int64
|
||||
var insuff atomic.Int64
|
||||
startGate := make(chan struct{})
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startGate
|
||||
err := svc.ConsumeCredits(ctx, companyID, 0, "product_processing")
|
||||
if err == nil {
|
||||
okCount.Add(1)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrInsufficientCredits) {
|
||||
insuff.Add(1)
|
||||
return
|
||||
}
|
||||
t.Errorf("unexpected: %v", err)
|
||||
}()
|
||||
}
|
||||
close(startGate)
|
||||
wg.Wait()
|
||||
|
||||
if okCount.Load() != wallet {
|
||||
t.Fatalf("ok=%d want %d (insuff=%d)", okCount.Load(), wallet, insuff.Load())
|
||||
}
|
||||
if okCount.Load()+insuff.Load() != workers {
|
||||
t.Fatalf("ok+insuff=%d want %d", okCount.Load()+insuff.Load(), workers)
|
||||
}
|
||||
|
||||
var used, total int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
|
||||
Scan(&total, &used)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used != wallet || total != wallet {
|
||||
t.Fatalf("wallet total=%d used=%d want total=%d used=%d", total, used, wallet, wallet)
|
||||
}
|
||||
|
||||
var cycleUsed, products int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT credits_used, products_processed FROM billing_cycles
|
||||
WHERE company_id = $1 AND end_date > now()
|
||||
ORDER BY start_date DESC LIMIT 1`, companyID).Scan(&cycleUsed, &products)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cycleUsed != wallet || products != wallet {
|
||||
t.Fatalf("cycle used=%d products=%d want %d", cycleUsed, products, wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeCreditsBatchMatchesSummedBase(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "consume-credits-batch")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
||||
VALUES ($1, 100, 0, now())`, companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO billing_cycles (company_id, start_date, end_date, credits_used, products_processed)
|
||||
VALUES ($1, now() - interval '1 day', now() + interval '30 days', 0, 0)`, companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
_ = svc.EnsureDefaultCosts(ctx)
|
||||
|
||||
// 5 products, 0 tokens → DebitAmountN = 5 base credits, products_processed += 5.
|
||||
if err := svc.ConsumeCreditsBatch(ctx, companyID, 0, 5, "product_processing"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var used, products int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT cb.used_credits, bc.products_processed
|
||||
FROM credit_balances cb
|
||||
JOIN billing_cycles bc ON bc.company_id = cb.company_id AND bc.end_date > now()
|
||||
WHERE cb.company_id = $1
|
||||
ORDER BY bc.start_date DESC LIMIT 1`, companyID).Scan(&used, &products)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if used != 5 || products != 5 {
|
||||
t.Fatalf("used=%d products=%d want 5/5", used, products)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTokenPackMath(t *testing.T) {
|
||||
// Mirrors DebitAmount pack math: ceil(tokens/1000).
|
||||
cases := []struct {
|
||||
tokens int
|
||||
packs int
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 1},
|
||||
{1000, 1},
|
||||
{1001, 2},
|
||||
{2500, 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
packs := 0
|
||||
if c.tokens > 0 {
|
||||
packs = (c.tokens + 999) / 1000
|
||||
}
|
||||
got := DebitAmount(1, 1, c.tokens)
|
||||
want := 1 + packs
|
||||
if got != want {
|
||||
t.Fatalf("tokens=%d DebitAmount=%d want %d (packs=%d)", c.tokens, got, want, packs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateDebitMath(t *testing.T) {
|
||||
// Pure pack math aligned with EstimateDebit / ConsumeCredits (costs=1).
|
||||
cases := []struct {
|
||||
featureTokens int
|
||||
want int
|
||||
}{
|
||||
{0, 1}, // base feature cost only
|
||||
{1, 2},
|
||||
{1000, 2},
|
||||
{1001, 3},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := DebitAmount(1, 1, c.featureTokens)
|
||||
if got != c.want {
|
||||
t.Fatalf("tokens=%d debit=%d want %d", c.featureTokens, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package billing
|
||||
|
||||
import "strings"
|
||||
|
||||
// CreditsPerAIProduct is the typical wallet debit for one AI enhance
|
||||
// (product_processing base + one openai_token_k pack when tokens ≤ 1000).
|
||||
// Each content-language pass burns another ~CreditsPerAIProduct per product.
|
||||
// Included monthly grants assume AssumedPrimaryContentLanguages only.
|
||||
const CreditsPerAIProduct = 2
|
||||
|
||||
// AssumedPrimaryContentLanguages is how many content languages the included
|
||||
// monthly grant is sized for. Extra languages → credit packs or BYOK.
|
||||
const AssumedPrimaryContentLanguages = 1
|
||||
|
||||
// ScaleMaxProducts is the top self-serve SKU ceiling. Huge catalogs (1M+)
|
||||
// belong on Enterprise (sales-led credits / BYOK), not public Scale.
|
||||
const ScaleMaxProducts = 12_000
|
||||
|
||||
// PlanAICoverPercent is included monthly AI as a share of CreditSKUBase
|
||||
// (primary language only). Paid public plans use ~50% cover so Starter stays
|
||||
// lean (100 credits ≈ 50 AI products on a 100-SKU plan) and higher tiers
|
||||
// scale with CreditSKUBase ≤ PlanMaxProducts — not a full-catalog AI bundle.
|
||||
// Formula: credits = (CreditSKUBase × cover% / 100) × CreditsPerAIProduct.
|
||||
func PlanAICoverPercent(planName string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(planName)) {
|
||||
case "starter":
|
||||
return 50 // 100 × 50% × 2 = 100
|
||||
case "plus":
|
||||
return 50 // 400 × 50% × 2 = 400
|
||||
case "growth":
|
||||
return 50 // 1,200 × 50% × 2 = 1,200
|
||||
case "business":
|
||||
return 50 // 4,000 × 50% × 2 = 4,000
|
||||
case "scale":
|
||||
return 50 // 12,000 × 50% × 2 = 12,000
|
||||
case "enterprise":
|
||||
return 50 // display ladder only; grant is EnterpriseUnlimitedCredits
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// PlanMaxProducts is the hard SKU ceiling for a public plan name.
|
||||
// Slow retail ladder for small→mid shops; Scale reaches ScaleMaxProducts;
|
||||
// Enterprise is unlimited (nil). Credits sized via CreditSKUBase (≤ MaxProducts).
|
||||
func PlanMaxProducts(planName string) *int {
|
||||
mp := func(n int) *int { return &n }
|
||||
switch strings.ToLower(strings.TrimSpace(planName)) {
|
||||
case "free":
|
||||
return mp(50)
|
||||
case "starter":
|
||||
return mp(100)
|
||||
case "plus":
|
||||
return mp(400)
|
||||
case "growth":
|
||||
return mp(1_200)
|
||||
case "business":
|
||||
return mp(4_000)
|
||||
case "scale":
|
||||
return mp(ScaleMaxProducts)
|
||||
default:
|
||||
// Enterprise and unknown custom plans — unlimited SKU cap.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// CreditSKUBase is the catalog size used ONLY to size included monthly AI credits.
|
||||
// May be smaller than PlanMaxProducts so large catalogs still get a bounded AI starter grant.
|
||||
// Public paid ladder: base equals PlanMaxProducts (50% cover → half-catalog primary-lang AI).
|
||||
func CreditSKUBase(planName string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(planName)) {
|
||||
case "starter":
|
||||
return 100
|
||||
case "plus":
|
||||
return 400
|
||||
case "growth":
|
||||
return 1_200
|
||||
case "business":
|
||||
return 4_000
|
||||
case "scale":
|
||||
return 12_000
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// MonthlyCreditsForSKUCover returns credits for coverPct% of skuBase at CreditsPerAIProduct each.
|
||||
func MonthlyCreditsForSKUCover(skuBase, coverPct int) int {
|
||||
if skuBase <= 0 || coverPct <= 0 {
|
||||
return 0
|
||||
}
|
||||
if coverPct > 100 {
|
||||
coverPct = 100
|
||||
}
|
||||
products := (skuBase * coverPct) / 100
|
||||
return products * CreditsPerAIProduct * AssumedPrimaryContentLanguages
|
||||
}
|
||||
|
||||
// MonthlyCreditsForPlan sizes the monthly grant from CreditSKUBase × PlanAICoverPercent.
|
||||
// The maxProducts argument is ignored when CreditSKUBase is set (paid public ladder).
|
||||
func MonthlyCreditsForPlan(planName string, maxProducts int) int {
|
||||
base := CreditSKUBase(planName)
|
||||
if base <= 0 {
|
||||
base = maxProducts
|
||||
}
|
||||
return MonthlyCreditsForSKUCover(base, PlanAICoverPercent(planName))
|
||||
}
|
||||
|
||||
// CreditPack is a one-time AI credit top-up sold via Stripe Checkout (mode=payment).
|
||||
// These are additional Stripe Products with one-time Prices — not subscription add-ons.
|
||||
type CreditPack struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Credits int `json:"credits"`
|
||||
PriceUSD int `json:"price_usd"` // whole dollars for marketing UI
|
||||
// Approx product×language passes at CreditsPerAIProduct.
|
||||
AIProducts int `json:"ai_products"`
|
||||
}
|
||||
|
||||
func packFromCredits(id, name, desc string, credits, priceUSD int) CreditPack {
|
||||
return CreditPack{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Description: desc,
|
||||
Credits: credits,
|
||||
PriceUSD: priceUSD,
|
||||
AIProducts: credits / CreditsPerAIProduct,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultCreditPacks is the public top-up ladder (credits-first sizing).
|
||||
// Prices are balanced so small packs are not punitive $/credit vs larger ones,
|
||||
// while staying expensive enough that packs cannot undercut plan upgrades or A1 (~€300).
|
||||
func DefaultCreditPacks() []CreditPack {
|
||||
return []CreditPack{
|
||||
packFromCredits("tiny", "Nano pack", "Smoke tests / tiny fixes (25 credits ≈ 12 AI products)", 25, 29),
|
||||
packFromCredits("small", "Starter pack", "Small top-up (65 credits ≈ 32 AI products)", 65, 59),
|
||||
packFromCredits("medium", "Plus pack", "Burst top-up (200 credits ≈ 100 AI products)", 200, 149),
|
||||
packFromCredits("large", "Growth pack", "Mid buffer (500 credits ≈ 250 AI products)", 500, 299),
|
||||
packFromCredits("xl", "Catalog pack", "Catalog / re-run buffer (1,200 credits ≈ 600 AI products)", 1200, 599),
|
||||
packFromCredits("xxl", "Business pack", "Large multi-language buffer (3,000 credits ≈ 1,500 AI products)", 3000, 1299),
|
||||
packFromCredits("mega", "Scale pack", "Distributor / agency burst (8,000 credits ≈ 4,000 AI products)", 8000, 2999),
|
||||
}
|
||||
}
|
||||
|
||||
// CreditPackByID returns a pack from DefaultCreditPacks.
|
||||
func CreditPackByID(id string) (CreditPack, bool) {
|
||||
want := strings.ToLower(strings.TrimSpace(id))
|
||||
for _, p := range DefaultCreditPacks() {
|
||||
if p.ID == want {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return CreditPack{}, false
|
||||
}
|
||||
|
||||
// CreditPackPriceKey is the Stripe PriceIDs map key for a one-time pack (pack:<id>).
|
||||
func CreditPackPriceKey(packID string) string {
|
||||
return "pack:" + strings.ToLower(strings.TrimSpace(packID))
|
||||
}
|
||||
|
||||
// CreditPackSettingsKey is the platformsettings Values key (stripe.price.pack.<id>).
|
||||
func CreditPackSettingsKey(packID string) string {
|
||||
return "stripe.price.pack." + strings.ToLower(strings.TrimSpace(packID))
|
||||
}
|
||||
|
||||
// CreditPackEnvVar is the optional process-env fallback (STRIPE_PRICE_PACK_<ID>).
|
||||
func CreditPackEnvVar(packID string) string {
|
||||
return "STRIPE_PRICE_PACK_" + strings.ToUpper(strings.TrimSpace(packID))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemainingCreditsClamped(t *testing.T) {
|
||||
cases := []struct {
|
||||
total, used, want int
|
||||
}{
|
||||
{100, 40, 60},
|
||||
{100, 100, 0},
|
||||
{100, 150, 0}, // used > total must not report negative
|
||||
{0, 0, 0},
|
||||
{0, 5, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := RemainingCreditsClamped(c.total, c.used)
|
||||
if got != c.want {
|
||||
t.Fatalf("total=%d used=%d got=%d want=%d", c.total, c.used, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyCreditDeltaPreventsNegativeRemaining(t *testing.T) {
|
||||
cases := []struct {
|
||||
total, used, amount, want int
|
||||
}{
|
||||
{100, 20, 50, 150},
|
||||
{100, 80, -50, 80}, // clawback stops at used
|
||||
{100, 80, -200, 80},
|
||||
{10, 0, -5, 5},
|
||||
{10, 0, -20, 0},
|
||||
{0, 0, 25, 25},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ApplyCreditDelta(c.total, c.used, c.amount)
|
||||
if got != c.want {
|
||||
t.Fatalf("total=%d used=%d amount=%d got=%d want=%d", c.total, c.used, c.amount, got, c.want)
|
||||
}
|
||||
if got < c.used {
|
||||
t.Fatalf("total after delta below used: got=%d used=%d", got, c.used)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEntitlementsClampsNegativeRemaining(t *testing.T) {
|
||||
ent := ComputeEntitlements("Free", 0, -10, false)
|
||||
if ent.RemainingCredits != 0 {
|
||||
t.Fatalf("remaining=%d want 0", ent.RemainingCredits)
|
||||
}
|
||||
if ent.CanUseAI {
|
||||
t.Fatal("negative remaining on Free must not unlock AI")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeCreditsContractErrors(t *testing.T) {
|
||||
// Document sentinel used by processOne / ProcessJob abort path.
|
||||
if !errors.Is(ErrInsufficientCredits, ErrInsufficientCredits) {
|
||||
t.Fatal("sentinel self-match")
|
||||
}
|
||||
wrapped := errors.New("x")
|
||||
if errors.Is(wrapped, ErrInsufficientCredits) {
|
||||
t.Fatal("unrelated error must not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitFloorAndNegativeTokens(t *testing.T) {
|
||||
if got := DebitAmount(1, 1, -5); got != 1 {
|
||||
t.Fatalf("DebitAmount negative tokens=%d want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitAmount(t *testing.T) {
|
||||
cases := []struct {
|
||||
feature, tokenK, tokens, want int
|
||||
}{
|
||||
{1, 1, 0, 1},
|
||||
{1, 1, 1, 2},
|
||||
{1, 1, 1000, 2},
|
||||
{1, 1, 1001, 3},
|
||||
{2, 3, 2500, 2 + 3*3}, // base 2 + 3 packs * 3
|
||||
{0, 0, 0, 1}, // floors
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := DebitAmount(c.feature, c.tokenK, c.tokens)
|
||||
if got != c.want {
|
||||
t.Fatalf("DebitAmount(%d,%d,%d)=%d want %d", c.feature, c.tokenK, c.tokens, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitAmountNVsPerProduct(t *testing.T) {
|
||||
// Exact parity when each item's tokens don't leave partial packs that merge.
|
||||
sum := DebitAmount(1, 1, 1000) + DebitAmount(1, 1, 1000)
|
||||
batchedExact := DebitAmountN(1, 1, 2000, 2)
|
||||
if sum != batchedExact {
|
||||
t.Fatalf("aligned packs: sum=%d batch=%d", sum, batchedExact)
|
||||
}
|
||||
|
||||
// Combined packs can undercharge vs per-item ceil.
|
||||
perItem := DebitAmount(1, 1, 500) + DebitAmount(1, 1, 500) // 2+2=4
|
||||
batched := DebitAmountN(1, 1, 1000, 2) // 2*1 + 1 = 3
|
||||
if perItem <= batched {
|
||||
t.Fatalf("expected batch undercharge: perItem=%d batched=%d", perItem, batched)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeCreditsSkipsEntitlementsOnAITokens(t *testing.T) {
|
||||
// Document hot-path contract: tokenCount > 0 skips EntitlementsForCompany.
|
||||
// Flat (0-token) Free-plan burn still gates via !CanUseAI.
|
||||
tokenCount := 1200
|
||||
needEntitlements := tokenCount == 0
|
||||
if needEntitlements {
|
||||
t.Fatal("AI token debit must not require entitlements preflight")
|
||||
}
|
||||
tokenCount = 0
|
||||
if !(tokenCount == 0) {
|
||||
t.Fatal("flat debit still gates entitlements")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// IsCustomPackage reports whether a plan should get the "custom deal" feature
|
||||
// treatment (all dashboard features ON by default for non-A1 deals).
|
||||
//
|
||||
// Product semantics (see IsPublicProductPlan + plans.is_custom):
|
||||
// - Client / admin deals with is_custom=true → custom (including A1 PAYG)
|
||||
// - Public Enterprise (and any row with is_custom=true) → custom
|
||||
// - Exact "Legacy" plan name → never enable-all (restricted migrated matrix)
|
||||
// - Free / Starter / Growth / Business with is_custom=false → not custom
|
||||
//
|
||||
// is_custom wins over A1* name patterns for PAYG billing / PlanProfileCustom,
|
||||
// but A1* custom deals use A1PaygPlanFeatures (not literal enable-all) so
|
||||
// Stores, Marketing, and Integrations stay off.
|
||||
func IsCustomPackage(name string, isCustom bool) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
|
||||
return false
|
||||
}
|
||||
if isCustom {
|
||||
return true
|
||||
}
|
||||
// Legacy-named plans without is_custom stay on the limited matrix.
|
||||
if IsLegacyPlanName(name) {
|
||||
return false
|
||||
}
|
||||
return !IsPublicProductPlan(name)
|
||||
}
|
||||
|
||||
// A1PaygFeatureDenied reports keys that stay OFF on A1 PAYG custom deals.
|
||||
// Nav: Stores → stores.*; Marketing → marketing.*; Integrations → integrations.*.
|
||||
// Cutover honesty chrome (ETL gaps / reconnect / migrated checklist) stays OFF — A1 is not a
|
||||
// hypercare merchant surface (see MigratedEtlGapsPanel + IsA1CohortCompany product rules).
|
||||
func A1PaygFeatureDenied(key string) bool {
|
||||
switch key {
|
||||
case "dashboard.etl_gaps", "dashboard.store_reconnect", "dashboard.migrated_checklist":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(key, "stores.") ||
|
||||
strings.HasPrefix(key, "marketing.") ||
|
||||
strings.HasPrefix(key, "integrations.")
|
||||
}
|
||||
|
||||
// A1PaygPlanFeatures is the dump-faithful A1 PAYG matrix: custom enable-all
|
||||
// minus Stores, Marketing, and Integrations sections.
|
||||
func A1PaygPlanFeatures() map[string]bool {
|
||||
out := AllRegistryFeatures(true)
|
||||
for k := range out {
|
||||
if A1PaygFeatureDenied(k) {
|
||||
out[k] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SparseA1PaygOverrides returns explicit false overrides for A1 PAYG denied keys.
|
||||
func SparseA1PaygOverrides() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if A1PaygFeatureDenied(k) {
|
||||
out[k] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AllRegistryFeatures returns every FeatureCatalogKeys entry set to enabled.
|
||||
func AllRegistryFeatures(enabled bool) map[string]bool {
|
||||
out := make(map[string]bool, len(FeatureCatalogKeys))
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
out[k] = enabled
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnableSectionForAllPlans turns a section master switch ON for every plan
|
||||
// (global gate; missing rows already default ON).
|
||||
func (s *Service) EnableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
|
||||
return s.SetSectionGate(ctx, section, true, updatedBy)
|
||||
}
|
||||
|
||||
// DisableSectionForAllPlans turns a section master switch OFF for every plan.
|
||||
func (s *Service) DisableSectionForAllPlans(ctx context.Context, section string, updatedBy *uuid.UUID) (FeatureGatesView, error) {
|
||||
return s.SetSectionGate(ctx, section, false, updatedBy)
|
||||
}
|
||||
|
||||
// prepareCustomPackageCreateFeatures applies create-time defaults for custom packages:
|
||||
// when the caller omitted features, materialize enable-all overrides so admin UIs
|
||||
// show an explicit all-ON matrix (resolve already treats empty+is_custom as all ON).
|
||||
func prepareCustomPackageCreateFeatures(p *Plan, creating, featuresProvided bool) {
|
||||
if !creating {
|
||||
return
|
||||
}
|
||||
if IsLegacyPlan(p.Name, p.IsLegacy) && !IsCustomPackage(p.Name, p.IsCustom) {
|
||||
p.IsLegacy = true
|
||||
if strings.EqualFold(strings.TrimSpace(p.Name), LegacyPlanName) {
|
||||
p.IsCustom = false
|
||||
} else if !IsPublicProductPlan(p.Name) {
|
||||
p.IsCustom = true
|
||||
}
|
||||
if !featuresProvided {
|
||||
p.Features = SparseLegacyOverrides()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non-public ladder names are client deals — keep is_custom aligned.
|
||||
if !IsPublicProductPlan(p.Name) {
|
||||
p.IsCustom = true
|
||||
}
|
||||
// A1 PAYG / custom deals are never the restricted Legacy matrix.
|
||||
if IsCustomPackage(p.Name, p.IsCustom) {
|
||||
p.IsLegacy = false
|
||||
}
|
||||
if featuresProvided {
|
||||
return
|
||||
}
|
||||
if IsCustomPackage(p.Name, p.IsCustom) {
|
||||
if IsLegacyPlanName(p.Name) {
|
||||
p.Features = A1PaygPlanFeatures()
|
||||
return
|
||||
}
|
||||
p.Features = AllRegistryFeatures(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsCustomPackage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
isCustom bool
|
||||
want bool
|
||||
}{
|
||||
{"Free", false, false},
|
||||
{"Starter", false, false},
|
||||
{"Growth", false, false},
|
||||
{"Business", false, false},
|
||||
{"Enterprise", true, true},
|
||||
{"Enterprise", false, false}, // public ladder without is_custom flag
|
||||
{"A1", false, false}, // legacy name without is_custom → limited matrix
|
||||
{"A1", true, true}, // dump-faithful A1 PAYG is_custom → custom profile (Stores/AI still gated)
|
||||
{"Legacy", true, false}, // exact Legacy package never enable-all
|
||||
{"Merkur trial", false, true},
|
||||
{" growth ", false, false},
|
||||
{"", false, true}, // empty name is not a public plan name
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsCustomPackage(tc.name, tc.isCustom)
|
||||
if got != tc.want {
|
||||
t.Fatalf("IsCustomPackage(%q, %v)=%v want %v", tc.name, tc.isCustom, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllRegistryFeatures(t *testing.T) {
|
||||
on := AllRegistryFeatures(true)
|
||||
off := AllRegistryFeatures(false)
|
||||
if len(on) != len(FeatureCatalogKeys) || len(off) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("len on=%d off=%d catalog=%d", len(on), len(off), len(FeatureCatalogKeys))
|
||||
}
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !on[k] {
|
||||
t.Fatalf("expected %s enabled", k)
|
||||
}
|
||||
if off[k] {
|
||||
t.Fatalf("expected %s disabled", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareCustomPackageCreateFeatures(t *testing.T) {
|
||||
t.Run("custom create without features enables all", func(t *testing.T) {
|
||||
p := Plan{Name: "ClientCo Deal", IsCustom: true}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if p.Features == nil || len(p.Features) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("expected full enable-all features, got %#v", p.Features)
|
||||
}
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !p.Features[k] {
|
||||
t.Fatalf("key %s not enabled", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("non-public name forces is_custom", func(t *testing.T) {
|
||||
p := Plan{Name: "Merkur", IsCustom: false}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if !p.IsCustom {
|
||||
t.Fatal("expected is_custom forced true for client deal")
|
||||
}
|
||||
if len(p.Features) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("expected enable-all after force custom, got %d keys", len(p.Features))
|
||||
}
|
||||
})
|
||||
t.Run("public free create leaves features nil", func(t *testing.T) {
|
||||
p := Plan{Name: "Free", IsCustom: false}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if p.Features != nil {
|
||||
t.Fatalf("standard Free must not materialize features: %#v", p.Features)
|
||||
}
|
||||
})
|
||||
t.Run("explicit features respected", func(t *testing.T) {
|
||||
p := Plan{Name: "A1", IsCustom: true, Features: map[string]bool{"catalog.products": false}}
|
||||
prepareCustomPackageCreateFeatures(&p, true, true)
|
||||
if p.Features["catalog.products"] != false || len(p.Features) != 1 {
|
||||
t.Fatalf("explicit features overwritten: %#v", p.Features)
|
||||
}
|
||||
})
|
||||
t.Run("update does not rewrite", func(t *testing.T) {
|
||||
p := Plan{Name: "A1", IsCustom: true, ID: 9}
|
||||
prepareCustomPackageCreateFeatures(&p, false, false)
|
||||
if p.Features != nil {
|
||||
t.Fatalf("update must not inject features: %#v", p.Features)
|
||||
}
|
||||
})
|
||||
t.Run("enterprise is_custom create enables all", func(t *testing.T) {
|
||||
p := Plan{Name: "Enterprise", IsCustom: true}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if len(p.Features) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("enterprise custom create should enable all, got %d", len(p.Features))
|
||||
}
|
||||
})
|
||||
t.Run("A1 custom create uses PAYG matrix without Stores/Marketing/Integrations", func(t *testing.T) {
|
||||
p := Plan{Name: "A1", IsCustom: true}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if p.IsLegacy {
|
||||
t.Fatal("A1 PAYG create must clear is_legacy")
|
||||
}
|
||||
if !p.IsCustom {
|
||||
t.Fatal("A1 remains a client deal (is_custom)")
|
||||
}
|
||||
if !p.Features["processing.monitor"] {
|
||||
t.Fatal("A1 PAYG must enable processing.monitor")
|
||||
}
|
||||
if !p.Features["capability.eprel"] {
|
||||
t.Fatal("A1 PAYG must enable capability.eprel")
|
||||
}
|
||||
if p.Features["stores.hub"] || p.Features["marketing.campaigns"] || p.Features["integrations.ai"] || p.Features["integrations.email"] {
|
||||
t.Fatal("A1 PAYG must deny stores, marketing, and integrations")
|
||||
}
|
||||
if len(p.Features) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("expected full tailored matrix, got %d keys", len(p.Features))
|
||||
}
|
||||
})
|
||||
t.Run("Legacy create seeds legacy sparse", func(t *testing.T) {
|
||||
p := Plan{Name: "Legacy", IsCustom: false, IsLegacy: true}
|
||||
prepareCustomPackageCreateFeatures(&p, true, false)
|
||||
if !p.IsLegacy {
|
||||
t.Fatal("Legacy create must set is_legacy")
|
||||
}
|
||||
if p.Features["processing.monitor"] {
|
||||
t.Fatal("Legacy must not enable processing.monitor")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultPlanFeaturesCustomUsesIsCustomPackage(t *testing.T) {
|
||||
// Non-legacy client deal without is_custom flag still all-ON via name.
|
||||
m := DefaultPlanFeatures("ClientCo Deal", false)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !m[k] {
|
||||
t.Fatalf("client deal default missing %s", k)
|
||||
}
|
||||
}
|
||||
// A1 without is_custom stays legacy — limited matrix.
|
||||
a1 := DefaultPlanFeatures("A1", false)
|
||||
if a1["processing.monitor"] {
|
||||
t.Fatal("legacy A1 (is_custom=false) must keep processing.monitor off")
|
||||
}
|
||||
a1Payg := DefaultPlanFeatures("A1", true)
|
||||
if !a1Payg["processing.monitor"] || !a1Payg["capability.eprel"] {
|
||||
t.Fatal("A1 PAYG is_custom must enable core PAYG features")
|
||||
}
|
||||
if a1Payg["stores.hub"] || a1Payg["stores.shopify"] || a1Payg["marketing.campaigns"] || a1Payg["integrations.ai"] || a1Payg["integrations.ai.byok"] || a1Payg["integrations.email"] {
|
||||
t.Fatal("A1 PAYG must keep Stores, Marketing, and Integrations off")
|
||||
}
|
||||
free := DefaultPlanFeatures("Free", false)
|
||||
if free["capability.ai_processing"] {
|
||||
t.Fatal("Free should keep AI processing off by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAllowsFeatureCustomByName(t *testing.T) {
|
||||
if !PlanAllowsFeature("Merkur trial", false, nil, "capability.byok") {
|
||||
t.Fatal("non-public package should allow all keys when overrides empty")
|
||||
}
|
||||
if PlanAllowsFeature("Free", false, nil, "capability.byok") {
|
||||
t.Fatal("Free should deny byok by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveFeaturesSectionGate(t *testing.T) {
|
||||
gates := emptyGatesView()
|
||||
gates.Sections["marketing"] = false
|
||||
features, sections, disabled := ResolveEffectiveFeatures("Merkur", true, nil, gates)
|
||||
if sections["marketing"] {
|
||||
t.Fatal("marketing section should be off")
|
||||
}
|
||||
if features["marketing.campaigns"] {
|
||||
t.Fatal("marketing.campaigns should be effective-false when section off")
|
||||
}
|
||||
found := false
|
||||
for _, d := range disabled {
|
||||
if d == "marketing.campaigns" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("marketing.campaigns should appear in disabled list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Concurrent claimAndRollDueCompanyPlan must roll a due company_plan exactly once.
|
||||
func TestClaimAndRollDueCompanyPlanConcurrent(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-claim-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
var planID int64
|
||||
err = pg.QueryRow(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, term)
|
||||
VALUES ($1, $2, $3, 'monthly')
|
||||
RETURNING id`, "claim-test-plan-"+companyID.String()[:8], "integration", 100).Scan(&planID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, planID)
|
||||
})
|
||||
|
||||
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
|
||||
nextBill := time.Now().UTC().Add(-time.Hour)
|
||||
var rowID int64
|
||||
err = pg.QueryRow(ctx, `
|
||||
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
|
||||
VALUES ($1, $2, true, $3, $4)
|
||||
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
||||
VALUES ($1, 100, 17, now())`, companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, workers)
|
||||
oks := make(chan bool, workers)
|
||||
startGate := make(chan struct{})
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-startGate
|
||||
ok, runErr := svc.claimAndRollDueCompanyPlan(ctx, rowID)
|
||||
if runErr != nil {
|
||||
errs <- runErr
|
||||
return
|
||||
}
|
||||
oks <- ok
|
||||
}()
|
||||
}
|
||||
close(startGate)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(oks)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("claimAndRollDueCompanyPlan: %v", err)
|
||||
}
|
||||
|
||||
successes := 0
|
||||
for ok := range oks {
|
||||
if ok {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("expected exactly 1 successful claim, got %d", successes)
|
||||
}
|
||||
|
||||
var cycleCount int
|
||||
err = pg.QueryRow(ctx, `SELECT COUNT(*) FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&cycleCount)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cycleCount != 1 {
|
||||
t.Fatalf("billing_cycles rows=%d want 1", cycleCount)
|
||||
}
|
||||
|
||||
var creditsUsed int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT credits_used FROM billing_cycles WHERE company_id = $1`, companyID).Scan(&creditsUsed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creditsUsed != 17 {
|
||||
t.Fatalf("credits_used=%d want 17", creditsUsed)
|
||||
}
|
||||
|
||||
var stillDue bool
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT next_billing_date <= now()
|
||||
FROM company_plans WHERE id = $1`, rowID).Scan(&stillDue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stillDue {
|
||||
t.Fatal("company_plans still due after roll")
|
||||
}
|
||||
|
||||
var total, used int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total, &used)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 100 || used != 0 {
|
||||
t.Fatalf("credit_balances total=%d used=%d want 100/0", total, used)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDueBillingCyclesBestEffortMultiCompany(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
type fixture struct {
|
||||
companyID uuid.UUID
|
||||
planID int64
|
||||
rowID int64
|
||||
}
|
||||
var fixtures []fixture
|
||||
for i := 0; i < 2; i++ {
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "billing-cycle-multi-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cid := companyID
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, cid)
|
||||
})
|
||||
|
||||
var planID int64
|
||||
err = pg.QueryRow(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, term)
|
||||
VALUES ($1, $2, $3, 'monthly')
|
||||
RETURNING id`, "multi-plan-"+companyID.String()[:8], "integration", 80).Scan(&planID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid := planID
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM plans WHERE id = $1`, pid)
|
||||
})
|
||||
|
||||
cycleStart := time.Now().UTC().AddDate(0, -1, 0)
|
||||
nextBill := time.Now().UTC().Add(-time.Hour)
|
||||
var rowID int64
|
||||
err = pg.QueryRow(ctx, `
|
||||
INSERT INTO company_plans (company_id, plan_id, is_active, billing_cycle_start, next_billing_date)
|
||||
VALUES ($1, $2, true, $3, $4)
|
||||
RETURNING id`, companyID, planID, cycleStart, nextBill).Scan(&rowID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO credit_balances (company_id, total_credits, used_credits, updated_at)
|
||||
VALUES ($1, 80, 3, now())`, companyID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixtures = append(fixtures, fixture{companyID: companyID, planID: planID, rowID: rowID})
|
||||
}
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
res, runErr := svc.RunDueBillingCycles(ctx)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunDueBillingCycles: %v", runErr)
|
||||
}
|
||||
if res.Processed < 2 {
|
||||
t.Fatalf("processed=%d want >=2 (got failed=%d)", res.Processed, res.Failed)
|
||||
}
|
||||
if res.Failed != 0 {
|
||||
t.Fatalf("failed=%d want 0", res.Failed)
|
||||
}
|
||||
|
||||
for _, f := range fixtures {
|
||||
var stillDue bool
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT next_billing_date <= now()
|
||||
FROM company_plans WHERE id = $1`, f.rowID).Scan(&stillDue)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stillDue {
|
||||
t.Fatalf("company_plan %d still due after multi-company run", f.rowID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecordDueCycleAttempt(t *testing.T) {
|
||||
permanent := errors.New("insert failed")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
ok bool
|
||||
err error
|
||||
wantProcessed int
|
||||
wantFailed int
|
||||
wantErrSubstr string
|
||||
wantWrapped error
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
ok: true,
|
||||
wantProcessed: 1,
|
||||
},
|
||||
{
|
||||
name: "skipped claim is neither processed nor failed",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "permanent failure increments failed and wraps",
|
||||
err: permanent,
|
||||
wantFailed: 1,
|
||||
wantErrSubstr: "company_plan 42:",
|
||||
wantWrapped: permanent,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var res DueBillingCyclesResult
|
||||
gotErr := recordDueCycleAttempt(&res, 42, tc.ok, tc.err)
|
||||
if res.Processed != tc.wantProcessed || res.Failed != tc.wantFailed {
|
||||
t.Fatalf("processed=%d failed=%d want processed=%d failed=%d",
|
||||
res.Processed, res.Failed, tc.wantProcessed, tc.wantFailed)
|
||||
}
|
||||
if tc.wantErrSubstr == "" {
|
||||
if gotErr != nil {
|
||||
t.Fatalf("unexpected err: %v", gotErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if gotErr == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(gotErr.Error(), tc.wantErrSubstr) {
|
||||
t.Fatalf("err=%q missing %q", gotErr.Error(), tc.wantErrSubstr)
|
||||
}
|
||||
if tc.wantWrapped != nil && !errors.Is(gotErr, tc.wantWrapped) {
|
||||
t.Fatalf("errors.Is(%v, %v)=false", gotErr, tc.wantWrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDueCycleAttemptBestEffortAggregation(t *testing.T) {
|
||||
var res DueBillingCyclesResult
|
||||
var errs []error
|
||||
|
||||
for _, attempt := range []struct {
|
||||
rowID int64
|
||||
ok bool
|
||||
err error
|
||||
}{
|
||||
{1, true, nil},
|
||||
{2, false, errors.New("update failed")},
|
||||
{3, false, nil},
|
||||
{4, true, nil},
|
||||
{5, false, errors.New("commit failed")},
|
||||
} {
|
||||
if attemptErr := recordDueCycleAttempt(&res, attempt.rowID, attempt.ok, attempt.err); attemptErr != nil {
|
||||
errs = append(errs, attemptErr)
|
||||
}
|
||||
}
|
||||
|
||||
if res.Processed != 2 || res.Failed != 2 {
|
||||
t.Fatalf("processed=%d failed=%d want 2/2", res.Processed, res.Failed)
|
||||
}
|
||||
joined := errors.Join(errs...)
|
||||
if joined == nil {
|
||||
t.Fatal("expected aggregated error")
|
||||
}
|
||||
msg := joined.Error()
|
||||
for _, want := range []string{"company_plan 2:", "company_plan 5:", "update failed", "commit failed"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("aggregated err %q missing %q", msg, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnsureDefaultFeatureSeeds idempotently seeds global section master switches
|
||||
// (marketing + integrations forced OFF; other sections default ON). Plan feature
|
||||
// overrides stay sparse: empty '{}' means unset and DefaultPlanFeatures /
|
||||
// is_custom apply at resolve time.
|
||||
//
|
||||
// ASSUMPTION: There is no plans.features_customized flag. A non-empty
|
||||
// plans.features JSON object means an admin customized the package - this
|
||||
// seeder never overwrites it (except legacy-flagged plans — see
|
||||
// EnsureLegacyPlanFeatureSeeds). Empty '{}' means unset.
|
||||
// Custom / Enterprise (is_custom=true, non-legacy-name) resolve to all features ON.
|
||||
// A1* with is_custom resolve to A1PaygPlanFeatures (Stores/Marketing/Integrations off).
|
||||
// Legacy (A1 without is_custom / is_legacy) resolve to the image-nav matrix; empty rows are backfilled.
|
||||
func (s *Service) EnsureDefaultFeatureSeeds(ctx context.Context) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.seedGlobalSectionGates(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.EnsureLegacyDefaults(ctx)
|
||||
}
|
||||
|
||||
// EnsureLegacyPlanFeatureSeeds idempotently applies the legacy sparse matrix to
|
||||
// plans that are legacy by name or is_legacy flag.
|
||||
//
|
||||
// Rules:
|
||||
// - empty features → write SparseLegacyOverrides + mark is_legacy when column exists
|
||||
// - is_legacy=true → re-apply SparseLegacyOverrides (flagged cohort)
|
||||
// - non-empty customized (not enable-all) and not flagged → leave alone
|
||||
func (s *Service) EnsureLegacyPlanFeatureSeeds(ctx context.Context) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
|
||||
FROM plans`)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
return s.ensureLegacyPlanFeatureSeedsWithoutFlag(ctx)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
id int64
|
||||
name string
|
||||
isCustom bool
|
||||
isLegacy bool
|
||||
raw []byte
|
||||
}
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.name, &r.isCustom, &r.isLegacy, &r.raw); err != nil {
|
||||
return err
|
||||
}
|
||||
list = append(list, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
sparse := SparseLegacyOverrides()
|
||||
for _, r := range list {
|
||||
// Custom / PAYG deals (incl. A1 with is_custom) keep their own matrix —
|
||||
// never overwrite with legacy-sparse. A1 PAYG hygiene lives in ensureA1PaygPlanSemantics.
|
||||
if IsCustomPackage(r.name, r.isCustom) {
|
||||
continue
|
||||
}
|
||||
if !IsLegacyPlan(r.name, r.isLegacy) {
|
||||
continue
|
||||
}
|
||||
overrides, derr := decodeFeaturesJSON(r.raw)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
shouldWrite := r.isLegacy || featuresMapEmpty(overrides)
|
||||
if !shouldWrite {
|
||||
continue
|
||||
}
|
||||
if _, err := s.SetPlanFeatures(ctx, r.id, sparse); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET is_legacy = true, updated_at = now() WHERE id = $1 AND is_legacy = false`, r.id); err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ensureLegacyPlanFeatureSeedsWithoutFlag(ctx context.Context) error {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, is_custom, COALESCE(features, '{}'::jsonb)
|
||||
FROM plans`)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) || isUndefinedRelation(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
sparse := SparseLegacyOverrides()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
var isCustom bool
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &name, &isCustom, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if IsCustomPackage(name, isCustom) {
|
||||
continue
|
||||
}
|
||||
if !IsLegacyPlanName(name) {
|
||||
continue
|
||||
}
|
||||
overrides, derr := decodeFeaturesJSON(raw)
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
if !featuresMapEmpty(overrides) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.SetPlanFeatures(ctx, id, sparse); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) seedGlobalSectionGates(ctx context.Context) error {
|
||||
for _, section := range FeatureSections {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
|
||||
VALUES ($1, 'section', true, now())
|
||||
ON CONFLICT (gate_key) DO NOTHING`, section)
|
||||
if err != nil {
|
||||
if isUndefinedRelation(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Work-mode defaults: keep Marketing + Integrations off platform-wide.
|
||||
// Upsert so restarts re-assert OFF even if an older seed left them ON.
|
||||
for _, section := range []string{"marketing", "integrations"} {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at)
|
||||
VALUES ($1, 'section', false, now())
|
||||
ON CONFLICT (gate_key) DO UPDATE
|
||||
SET enabled = false,
|
||||
updated_at = now()
|
||||
WHERE platform_feature_gates.enabled IS DISTINCT FROM false`, section)
|
||||
if err != nil {
|
||||
if isUndefinedRelation(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.invalidateFeatureGatesCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SparseDefaultOverrides returns only the false keys from DefaultPlanFeatures
|
||||
// (empty map for custom / all-on packages). Legacy plans return SparseLegacyOverrides.
|
||||
// Useful for "reset to defaults" admin helpers without storing the full expanded matrix.
|
||||
func SparseDefaultOverrides(planName string, isCustom bool) map[string]bool {
|
||||
return SparseDefaultOverridesEx(planName, isCustom, IsLegacyPlanName(planName))
|
||||
}
|
||||
|
||||
// SparseDefaultOverridesEx includes an explicit is_legacy flag.
|
||||
func SparseDefaultOverridesEx(planName string, isCustom, isLegacy bool) map[string]bool {
|
||||
if IsCustomPackage(planName, isCustom) {
|
||||
if IsLegacyPlanName(planName) {
|
||||
return SparseA1PaygOverrides()
|
||||
}
|
||||
return map[string]bool{}
|
||||
}
|
||||
if IsLegacyPlan(planName, isLegacy) {
|
||||
return SparseLegacyOverrides()
|
||||
}
|
||||
full := DefaultPlanFeaturesEx(planName, false, false)
|
||||
out := make(map[string]bool)
|
||||
for k, v := range full {
|
||||
if !v {
|
||||
out[k] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NormalizePublicPlanName maps a plan name to the public ladder key used by
|
||||
// DefaultPlanFeatures (free|starter|plus|growth|business|scale|enterprise|other).
|
||||
func NormalizePublicPlanName(planName string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(planName)) {
|
||||
case "", "free":
|
||||
return "free"
|
||||
case "starter", "plus":
|
||||
// Plus uses the Starter feature matrix (AI on, BYOK off).
|
||||
return "starter"
|
||||
case "growth":
|
||||
return "growth"
|
||||
case "business", "scale":
|
||||
return "business"
|
||||
case "enterprise":
|
||||
return "enterprise"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPlanFeaturesMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
free := DefaultPlanFeatures("Free", false)
|
||||
if len(free) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("free matrix size=%d want %d", len(free), len(FeatureCatalogKeys))
|
||||
}
|
||||
for _, k := range []string{
|
||||
"catalog.products",
|
||||
"catalog.products.process_categories",
|
||||
"capability.normalize_specs_fill",
|
||||
"capability.eprel",
|
||||
"feeds.list",
|
||||
"billing.overview",
|
||||
} {
|
||||
if !free[k] {
|
||||
t.Fatalf("free should allow %s", k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{
|
||||
"catalog.products.process_ai_titles",
|
||||
"marketing.campaigns.generate_ai",
|
||||
"marketing.campaigns.send",
|
||||
"settings.api_keys",
|
||||
"capability.ai_processing",
|
||||
"capability.byok",
|
||||
"integrations.ai.byok",
|
||||
} {
|
||||
if free[k] {
|
||||
t.Fatalf("free should deny %s", k)
|
||||
}
|
||||
}
|
||||
|
||||
starter := DefaultPlanFeatures("Starter", false)
|
||||
if !starter["catalog.products.process_ai_titles"] {
|
||||
t.Fatal("starter should allow AI titles")
|
||||
}
|
||||
if starter["integrations.ai.byok"] || starter["capability.byok"] {
|
||||
t.Fatal("starter should deny BYOK")
|
||||
}
|
||||
|
||||
growth := DefaultPlanFeatures("Growth", false)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !growth[k] {
|
||||
t.Fatalf("growth should allow all keys; %s is off", k)
|
||||
}
|
||||
}
|
||||
|
||||
business := DefaultPlanFeatures("Business", false)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !business[k] {
|
||||
t.Fatalf("business should allow all keys; %s is off", k)
|
||||
}
|
||||
}
|
||||
|
||||
enterprise := DefaultPlanFeatures("Enterprise", true)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !enterprise[k] {
|
||||
t.Fatalf("enterprise custom should allow all keys; %s is off", k)
|
||||
}
|
||||
}
|
||||
|
||||
custom := DefaultPlanFeatures("ClientCo Deal", true)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !custom[k] {
|
||||
t.Fatalf("custom should allow all keys; %s is off", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparseDefaultOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
free := SparseDefaultOverrides("Free", false)
|
||||
if len(free) == 0 {
|
||||
t.Fatal("free sparse overrides should list denied keys")
|
||||
}
|
||||
for k, v := range free {
|
||||
if v {
|
||||
t.Fatalf("sparse override for %s should be false", k)
|
||||
}
|
||||
}
|
||||
if SparseDefaultOverrides("Growth", false) == nil {
|
||||
t.Fatal("expected empty map not nil")
|
||||
}
|
||||
if len(SparseDefaultOverrides("Growth", false)) != 0 {
|
||||
t.Fatal("growth sparse should be empty")
|
||||
}
|
||||
if len(SparseDefaultOverrides("Anything", true)) != 0 {
|
||||
t.Fatal("custom sparse should be empty")
|
||||
}
|
||||
if len(SparseDefaultOverrides("A1", false)) == 0 {
|
||||
t.Fatal("legacy A1 sparse should list denied keys")
|
||||
}
|
||||
a1PaygSparse := SparseDefaultOverrides("A1", true)
|
||||
if len(a1PaygSparse) == 0 {
|
||||
t.Fatal("A1 PAYG custom sparse should list Stores/AI denied keys")
|
||||
}
|
||||
if a1PaygSparse["stores.hub"] != false || a1PaygSparse["integrations.ai"] != false {
|
||||
t.Fatalf("A1 PAYG sparse must deny stores/AI: %#v", a1PaygSparse)
|
||||
}
|
||||
if _, ok := a1PaygSparse["processing.monitor"]; ok {
|
||||
t.Fatal("A1 PAYG sparse must not list allowed keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePublicPlanName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"": "free",
|
||||
"Free": "free",
|
||||
"STARTER": "starter",
|
||||
"Growth": "growth",
|
||||
"Business": "business",
|
||||
"Enterprise": "enterprise",
|
||||
"A1": "other",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := NormalizePublicPlanName(in); got != want {
|
||||
t.Fatalf("NormalizePublicPlanName(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAllowsFeatureUsesOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
if PlanAllowsFeature("Free", false, map[string]bool{"settings.api_keys": true}, "settings.api_keys") != true {
|
||||
t.Fatal("override true should win on free")
|
||||
}
|
||||
if PlanAllowsFeature("Free", false, nil, "settings.api_keys") != false {
|
||||
t.Fatal("free default denies api keys")
|
||||
}
|
||||
if PlanAllowsFeature("Deal", true, nil, "settings.api_keys") != true {
|
||||
t.Fatal("custom allows all")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Entitlements describes plan-gated capabilities for a company.
|
||||
type Entitlements struct {
|
||||
PlanName string `json:"plan_name"`
|
||||
IsFreePlan bool `json:"is_free_plan"`
|
||||
IsPaidPlan bool `json:"is_paid_plan"`
|
||||
IsTrial bool `json:"is_trial"`
|
||||
MonthlyCredits int `json:"monthly_credits"`
|
||||
RemainingCredits int `json:"remaining_credits"`
|
||||
// CanUseAI is true when remaining credits > 0 OR the company is on a paid plan (not Free).
|
||||
CanUseAI bool `json:"can_use_ai"`
|
||||
// CanUseEPREL is always true: EU EPREL is public free data (no credits). Platform may still disable the enricher via eprel.enabled / EPREL_ENABLED.
|
||||
CanUseEPREL bool `json:"can_use_eprel"`
|
||||
}
|
||||
|
||||
// ErrAIRequiresUpgrade is returned when a job is AI-only and the company cannot use AI.
|
||||
var ErrAIRequiresUpgrade = errors.New("ai features require a paid plan or AI credits")
|
||||
|
||||
// ErrEPRELRequiresUpgrade is retained for API error shaping only; CanUseEPREL is always true
|
||||
// (public EU data, no credits). Do not surface "upgrade for EPREL" in product copy.
|
||||
var ErrEPRELRequiresUpgrade = errors.New("EPREL enrichment unavailable")
|
||||
|
||||
// IsFreePlanName reports whether the plan name is the forever-free tier.
|
||||
func IsFreePlanName(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), "free")
|
||||
}
|
||||
|
||||
// RemainingCreditsClamped returns max(0, total-used) so corrupted wallets never report negative spendable credits.
|
||||
func RemainingCreditsClamped(total, used int) int {
|
||||
r := total - used
|
||||
if r < 0 {
|
||||
return 0
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ApplyCreditDelta returns the next total_credits after amount (grants or clawbacks).
|
||||
// Never below used_credits or below 0 — prevents negative remaining balances.
|
||||
func ApplyCreditDelta(total, used, amount int) int {
|
||||
next := total + amount
|
||||
if next < used {
|
||||
next = used
|
||||
}
|
||||
if next < 0 {
|
||||
next = 0
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// DebitAmount is the per-product credit burn: feature base + ceil(tokens/1000)*tokenK.
|
||||
// Negative tokenCount is treated as 0. Costs below 1 fall back to 1.
|
||||
func DebitAmount(featureCost, tokenKCost, tokenCount int) int {
|
||||
if featureCost < 1 {
|
||||
featureCost = 1
|
||||
}
|
||||
if tokenCount < 0 {
|
||||
tokenCount = 0
|
||||
}
|
||||
debit := featureCost
|
||||
if tokenCount > 0 {
|
||||
if tokenKCost < 1 {
|
||||
tokenKCost = 1
|
||||
}
|
||||
packs := (tokenCount + 999) / 1000
|
||||
debit += packs * tokenKCost
|
||||
}
|
||||
if debit < 1 {
|
||||
debit = 1
|
||||
}
|
||||
return debit
|
||||
}
|
||||
|
||||
// DebitAmountN scales the feature base by productCount and adds token packs on the
|
||||
// combined tokenCount. packs(sum) can be less than sum(packs) — for exact parity with
|
||||
// N×ConsumeCredits, sum DebitAmount per item instead of using this helper.
|
||||
func DebitAmountN(featureCost, tokenKCost, tokenCount, productCount int) int {
|
||||
if productCount < 1 {
|
||||
productCount = 1
|
||||
}
|
||||
if featureCost < 1 {
|
||||
featureCost = 1
|
||||
}
|
||||
if tokenCount < 0 {
|
||||
tokenCount = 0
|
||||
}
|
||||
debit := featureCost * productCount
|
||||
if tokenCount > 0 {
|
||||
if tokenKCost < 1 {
|
||||
tokenKCost = 1
|
||||
}
|
||||
packs := (tokenCount + 999) / 1000
|
||||
debit += packs * tokenKCost
|
||||
}
|
||||
if debit < 1 {
|
||||
debit = 1
|
||||
}
|
||||
return debit
|
||||
}
|
||||
|
||||
// ComputeEntitlements builds entitlements from plan + wallet state (pure; testable).
|
||||
func ComputeEntitlements(planName string, monthlyCredits, remaining int, isTrial bool) Entitlements {
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
free := IsFreePlanName(planName) || planName == ""
|
||||
paid := !free
|
||||
canAI := remaining > 0 || paid
|
||||
return Entitlements{
|
||||
PlanName: planName,
|
||||
IsFreePlan: free,
|
||||
IsPaidPlan: paid,
|
||||
IsTrial: isTrial,
|
||||
MonthlyCredits: monthlyCredits,
|
||||
RemainingCredits: remaining,
|
||||
CanUseAI: canAI,
|
||||
CanUseEPREL: true,
|
||||
}
|
||||
}
|
||||
|
||||
// EntitlementsForCompany loads active plan + credit wallet entitlements.
|
||||
func (s *Service) EntitlementsForCompany(ctx context.Context, companyID uuid.UUID) (Entitlements, error) {
|
||||
var total, used int
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
|
||||
Scan(&total, &used)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
total, used = 0, 0
|
||||
} else if err != nil {
|
||||
return Entitlements{}, err
|
||||
}
|
||||
remaining := RemainingCreditsClamped(total, used)
|
||||
|
||||
var planName string
|
||||
var monthly int
|
||||
var isTrial bool
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(p.name, ''), COALESCE(p.monthly_credits, 0), cp.is_trial
|
||||
FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&planName, &monthly, &isTrial)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ComputeEntitlements("Free", 0, remaining, false), nil
|
||||
}
|
||||
if err != nil {
|
||||
return Entitlements{}, err
|
||||
}
|
||||
return ComputeEntitlements(planName, monthly, remaining, isTrial), nil
|
||||
}
|
||||
|
||||
// ProcessingTypeRequiresAI reports whether the request is an AI-only intent
|
||||
// (cannot be silently downgraded to normalize/specs/fill).
|
||||
func ProcessingTypeRequiresAI(processingType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(processingType)) {
|
||||
case "enhance", "enhance_only", "enhance-only", "title", "description", "seo", "seo_ai":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessingTypeRequiresEPREL reports whether the request is EPREL-only.
|
||||
func ProcessingTypeRequiresEPREL(processingType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(processingType)) {
|
||||
case "eprel", "eprel_only":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessingTypeIsEmailCampaignAI is reserved for future email-campaign AI endpoints.
|
||||
func ProcessingTypeIsEmailCampaignAI(processingType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(processingType)) {
|
||||
case "email_campaign", "email_campaign_ai", "campaign_ai":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package billing
|
||||
|
||||
// Code generated from docs/plan-permissions/01-feature-keys.json — do not hand-edit keys.
|
||||
|
||||
// FeatureCatalogKeys is the admin-validated registry of dashboard feature keys.
|
||||
var FeatureCatalogKeys = []string{
|
||||
"shell.navigation",
|
||||
"shell.command_palette",
|
||||
"shell.company_switcher",
|
||||
"shell.support_notifications",
|
||||
"shell.tutorial",
|
||||
"shell.account_menu",
|
||||
"shell.billing_recovery_banner",
|
||||
"dashboard.overview",
|
||||
"dashboard.stats",
|
||||
"dashboard.quick_links",
|
||||
"dashboard.recent_jobs",
|
||||
"dashboard.news_feed",
|
||||
"dashboard.activation_checklist",
|
||||
"dashboard.migrated_checklist",
|
||||
"dashboard.etl_gaps",
|
||||
"dashboard.store_reconnect",
|
||||
"dashboard.upgrade_banners",
|
||||
"catalog.products",
|
||||
"catalog.products.tab_processed",
|
||||
"catalog.products.tab_needs_review",
|
||||
"catalog.products.tab_error",
|
||||
"catalog.products.tab_processing",
|
||||
"catalog.products.tab_unprocessed",
|
||||
"catalog.products.process_categories",
|
||||
"catalog.products.process_attributes",
|
||||
"catalog.products.process_ai_titles",
|
||||
"catalog.products.process_ai_descriptions",
|
||||
"catalog.products.enrichment_review",
|
||||
"catalog.products.export_selection",
|
||||
"catalog.products.upgrade_prompt",
|
||||
"catalog.categories",
|
||||
"catalog.categories.title_formula",
|
||||
"catalog.categories.description_formula",
|
||||
"catalog.attributes",
|
||||
"catalog.attributes.bulk_import",
|
||||
"catalog.standard_fields",
|
||||
"catalog.standard_fields.groups",
|
||||
"catalog.structured_descriptions",
|
||||
"catalog.vector_categories",
|
||||
"feeds.list",
|
||||
"feeds.add_url",
|
||||
"feeds.add_csv",
|
||||
"feeds.sync",
|
||||
"feeds.mapping",
|
||||
"feeds.mapping.select_item",
|
||||
"feeds.mapping.map_fields",
|
||||
"feeds.export_feeds",
|
||||
"feeds.export_feeds.create",
|
||||
"feeds.export_feeds.generate",
|
||||
"feeds.uploads",
|
||||
"stores.hub",
|
||||
"stores.woocommerce",
|
||||
"stores.woocommerce.connection",
|
||||
"stores.woocommerce.categories",
|
||||
"stores.woocommerce.attributes",
|
||||
"stores.woocommerce.orders",
|
||||
"stores.woocommerce.reviews",
|
||||
"stores.woocommerce.settings",
|
||||
"stores.shopify",
|
||||
"stores.shopify.connection",
|
||||
"stores.shopify.orders",
|
||||
"stores.shopify.settings",
|
||||
"processing.monitor",
|
||||
"marketing.campaigns",
|
||||
"marketing.campaigns.create",
|
||||
"marketing.campaigns.generate_ai",
|
||||
"marketing.campaigns.send",
|
||||
"marketing.content_calendar",
|
||||
"marketing.brand_kit",
|
||||
"marketing.brand_ai_apply",
|
||||
"marketing.seo",
|
||||
"marketing.seo.template_fill",
|
||||
"marketing.seo.ai_rewrite",
|
||||
"marketing.reviews",
|
||||
"integrations.ai",
|
||||
"integrations.ai.byok",
|
||||
"integrations.email",
|
||||
"integrations.email.test",
|
||||
"integrations.email.blast",
|
||||
"billing.overview",
|
||||
"billing.customer_portal",
|
||||
"billing.quick_upgrade",
|
||||
"billing.plans_compare",
|
||||
"billing.checkout",
|
||||
"settings.profile",
|
||||
"settings.company",
|
||||
"settings.alerts",
|
||||
"settings.api_keys",
|
||||
"settings.team",
|
||||
"settings.team_invite",
|
||||
"support.center",
|
||||
"support.ticket_create",
|
||||
"support.ticket_thread",
|
||||
"capability.sku_cap",
|
||||
"capability.ai_credits",
|
||||
"capability.ai_processing",
|
||||
"capability.eprel",
|
||||
"capability.normalize_specs_fill",
|
||||
"capability.campaign_ai",
|
||||
"capability.email_live_send",
|
||||
"capability.brand_ai_apply",
|
||||
"capability.seo_ai_rewrite",
|
||||
"capability.feed_source_limit",
|
||||
"capability.export_feed_limit",
|
||||
"capability.storage_limit",
|
||||
"capability.api_access",
|
||||
"capability.byok",
|
||||
}
|
||||
|
||||
// FeatureSections are global section master-switch keys.
|
||||
var FeatureSections = []string{
|
||||
"shell",
|
||||
"dashboard",
|
||||
"catalog",
|
||||
"feeds",
|
||||
"stores",
|
||||
"processing",
|
||||
"marketing",
|
||||
"integrations",
|
||||
"billing",
|
||||
"settings",
|
||||
"support",
|
||||
"capabilities",
|
||||
}
|
||||
|
||||
var featureKeySection = map[string]string{
|
||||
"shell.navigation": "shell",
|
||||
"shell.command_palette": "shell",
|
||||
"shell.company_switcher": "shell",
|
||||
"shell.support_notifications": "shell",
|
||||
"shell.tutorial": "shell",
|
||||
"shell.account_menu": "shell",
|
||||
"shell.billing_recovery_banner": "shell",
|
||||
"dashboard.overview": "dashboard",
|
||||
"dashboard.stats": "dashboard",
|
||||
"dashboard.quick_links": "dashboard",
|
||||
"dashboard.recent_jobs": "dashboard",
|
||||
"dashboard.news_feed": "dashboard",
|
||||
"dashboard.activation_checklist": "dashboard",
|
||||
"dashboard.migrated_checklist": "dashboard",
|
||||
"dashboard.etl_gaps": "dashboard",
|
||||
"dashboard.store_reconnect": "dashboard",
|
||||
"dashboard.upgrade_banners": "dashboard",
|
||||
"catalog.products": "catalog",
|
||||
"catalog.products.tab_processed": "catalog",
|
||||
"catalog.products.tab_needs_review": "catalog",
|
||||
"catalog.products.tab_error": "catalog",
|
||||
"catalog.products.tab_processing": "catalog",
|
||||
"catalog.products.tab_unprocessed": "catalog",
|
||||
"catalog.products.process_categories": "catalog",
|
||||
"catalog.products.process_attributes": "catalog",
|
||||
"catalog.products.process_ai_titles": "catalog",
|
||||
"catalog.products.process_ai_descriptions": "catalog",
|
||||
"catalog.products.enrichment_review": "catalog",
|
||||
"catalog.products.export_selection": "catalog",
|
||||
"catalog.products.upgrade_prompt": "catalog",
|
||||
"catalog.categories": "catalog",
|
||||
"catalog.categories.title_formula": "catalog",
|
||||
"catalog.categories.description_formula": "catalog",
|
||||
"catalog.attributes": "catalog",
|
||||
"catalog.attributes.bulk_import": "catalog",
|
||||
"catalog.standard_fields": "catalog",
|
||||
"catalog.standard_fields.groups": "catalog",
|
||||
"catalog.structured_descriptions": "catalog",
|
||||
"catalog.vector_categories": "catalog",
|
||||
"feeds.list": "feeds",
|
||||
"feeds.add_url": "feeds",
|
||||
"feeds.add_csv": "feeds",
|
||||
"feeds.sync": "feeds",
|
||||
"feeds.mapping": "feeds",
|
||||
"feeds.mapping.select_item": "feeds",
|
||||
"feeds.mapping.map_fields": "feeds",
|
||||
"feeds.export_feeds": "feeds",
|
||||
"feeds.export_feeds.create": "feeds",
|
||||
"feeds.export_feeds.generate": "feeds",
|
||||
"feeds.uploads": "feeds",
|
||||
"stores.hub": "stores",
|
||||
"stores.woocommerce": "stores",
|
||||
"stores.woocommerce.connection": "stores",
|
||||
"stores.woocommerce.categories": "stores",
|
||||
"stores.woocommerce.attributes": "stores",
|
||||
"stores.woocommerce.orders": "stores",
|
||||
"stores.woocommerce.reviews": "stores",
|
||||
"stores.woocommerce.settings": "stores",
|
||||
"stores.shopify": "stores",
|
||||
"stores.shopify.connection": "stores",
|
||||
"stores.shopify.orders": "stores",
|
||||
"stores.shopify.settings": "stores",
|
||||
"processing.monitor": "processing",
|
||||
"marketing.campaigns": "marketing",
|
||||
"marketing.campaigns.create": "marketing",
|
||||
"marketing.campaigns.generate_ai": "marketing",
|
||||
"marketing.campaigns.send": "marketing",
|
||||
"marketing.content_calendar": "marketing",
|
||||
"marketing.brand_kit": "marketing",
|
||||
"marketing.brand_ai_apply": "marketing",
|
||||
"marketing.seo": "marketing",
|
||||
"marketing.seo.template_fill": "marketing",
|
||||
"marketing.seo.ai_rewrite": "marketing",
|
||||
"marketing.reviews": "marketing",
|
||||
"integrations.ai": "integrations",
|
||||
"integrations.ai.byok": "integrations",
|
||||
"integrations.email": "integrations",
|
||||
"integrations.email.test": "integrations",
|
||||
"integrations.email.blast": "integrations",
|
||||
"billing.overview": "billing",
|
||||
"billing.customer_portal": "billing",
|
||||
"billing.quick_upgrade": "billing",
|
||||
"billing.plans_compare": "billing",
|
||||
"billing.checkout": "billing",
|
||||
"settings.profile": "settings",
|
||||
"settings.company": "settings",
|
||||
"settings.alerts": "settings",
|
||||
"settings.api_keys": "settings",
|
||||
"settings.team": "settings",
|
||||
"settings.team_invite": "settings",
|
||||
"support.center": "support",
|
||||
"support.ticket_create": "support",
|
||||
"support.ticket_thread": "support",
|
||||
"capability.sku_cap": "capabilities",
|
||||
"capability.ai_credits": "capabilities",
|
||||
"capability.ai_processing": "capabilities",
|
||||
"capability.eprel": "capabilities",
|
||||
"capability.normalize_specs_fill": "capabilities",
|
||||
"capability.campaign_ai": "capabilities",
|
||||
"capability.email_live_send": "capabilities",
|
||||
"capability.brand_ai_apply": "capabilities",
|
||||
"capability.seo_ai_rewrite": "capabilities",
|
||||
"capability.feed_source_limit": "capabilities",
|
||||
"capability.export_feed_limit": "capabilities",
|
||||
"capability.storage_limit": "capabilities",
|
||||
"capability.api_access": "capabilities",
|
||||
"capability.byok": "capabilities",
|
||||
}
|
||||
|
||||
var featureCatalogSet = map[string]struct{}{}
|
||||
|
||||
func init() {
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
featureCatalogSet[k] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// SectionOfFeature returns the section for a registry feature key.
|
||||
func SectionOfFeature(key string) (string, bool) {
|
||||
s, ok := featureKeySection[key]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// IsKnownFeatureKey reports whether key is in the dashboard feature registry.
|
||||
func IsKnownFeatureKey(key string) bool {
|
||||
_, ok := featureCatalogSet[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsKnownFeatureSection reports whether section is a valid master-switch section.
|
||||
func IsKnownFeatureSection(section string) bool {
|
||||
for _, s := range FeatureSections {
|
||||
if s == section {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func freePlanFeatureOff(key string) bool {
|
||||
switch key {
|
||||
case "capability.ai_processing":
|
||||
return true
|
||||
case "capability.api_access":
|
||||
return true
|
||||
case "capability.brand_ai_apply":
|
||||
return true
|
||||
case "capability.byok":
|
||||
return true
|
||||
case "capability.campaign_ai":
|
||||
return true
|
||||
case "capability.email_live_send":
|
||||
return true
|
||||
case "capability.seo_ai_rewrite":
|
||||
return true
|
||||
case "catalog.products.process_ai_descriptions":
|
||||
return true
|
||||
case "catalog.products.process_ai_titles":
|
||||
return true
|
||||
case "integrations.ai.byok":
|
||||
return true
|
||||
case "marketing.brand_ai_apply":
|
||||
return true
|
||||
case "marketing.campaigns.generate_ai":
|
||||
return true
|
||||
case "marketing.campaigns.send":
|
||||
return true
|
||||
case "marketing.seo.ai_rewrite":
|
||||
return true
|
||||
case "settings.api_keys":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func starterPlanFeatureOff(key string) bool {
|
||||
switch key {
|
||||
case "capability.byok":
|
||||
return true
|
||||
case "integrations.ai.byok":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type featureKeyDoc struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// TestFeatureCatalogKeysMatchDocsJSON keeps Go FeatureCatalogKeys aligned with
|
||||
// docs/plan-permissions/01-feature-keys.json (shared with the web catalog).
|
||||
func TestFeatureCatalogKeysMatchDocsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..", ".."))
|
||||
path := filepath.Join(root, "docs", "plan-permissions", "01-feature-keys.json")
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var docs []featureKeyDoc
|
||||
if err := json.Unmarshal(raw, &docs); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
t.Fatal("docs feature keys empty")
|
||||
}
|
||||
want := make(map[string]struct{}, len(docs))
|
||||
for _, row := range docs {
|
||||
if row.Key == "" {
|
||||
t.Fatal("empty key in docs JSON")
|
||||
}
|
||||
want[row.Key] = struct{}{}
|
||||
}
|
||||
got := make(map[string]struct{}, len(FeatureCatalogKeys))
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
got[k] = struct{}{}
|
||||
}
|
||||
for k := range want {
|
||||
if _, ok := got[k]; !ok {
|
||||
t.Errorf("FeatureCatalogKeys missing docs key %q", k)
|
||||
}
|
||||
}
|
||||
for k := range got {
|
||||
if _, ok := want[k]; !ok {
|
||||
t.Errorf("FeatureCatalogKeys has extra key %q not in docs", k)
|
||||
}
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("FeatureCatalogKeys len=%d docs len=%d", len(got), len(want))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyAllowlistIncludesStorageLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
// roles-matrix legacy_user / plan_profiles.legacy list storage_limit ON.
|
||||
// Must not enable stores/marketing — only the marketing meter capability key.
|
||||
if !LegacyFeatureAllowed("capability.storage_limit") {
|
||||
t.Fatal("legacy allowlist must include capability.storage_limit (roles-matrix)")
|
||||
}
|
||||
if LegacyFeatureAllowed("stores.hub") || LegacyFeatureAllowed("marketing.campaigns") {
|
||||
t.Fatal("legacy must still deny stores/marketing (no A1 pollution)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FeatureKeyFromError extracts the feature key from an ErrFeatureDisabled wrap
|
||||
// ("feature_disabled: marketing.campaigns.generate_ai").
|
||||
func FeatureKeyFromError(err error) string {
|
||||
if err == nil || !errors.Is(err, ErrFeatureDisabled) {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
const prefix = "feature_disabled:"
|
||||
idx := strings.Index(strings.ToLower(msg), prefix)
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(msg[idx+len(prefix):])
|
||||
}
|
||||
|
||||
// FeatureKeysForProcessingType maps a processing job type to registry keys that
|
||||
// must be effective before StartJob may proceed.
|
||||
func FeatureKeysForProcessingType(processingType string) []string {
|
||||
switch strings.ToLower(strings.TrimSpace(processingType)) {
|
||||
case "title":
|
||||
return []string{"capability.ai_processing", "catalog.products.process_ai_titles"}
|
||||
case "description":
|
||||
return []string{"capability.ai_processing", "catalog.products.process_ai_descriptions"}
|
||||
case "enhance", "enhance_only", "enhance-only", "seo", "seo_ai":
|
||||
return []string{"capability.ai_processing"}
|
||||
case "eprel", "eprel_only":
|
||||
return []string{"capability.eprel"}
|
||||
case "email_campaign", "email_campaign_ai", "campaign_ai":
|
||||
return []string{"capability.campaign_ai", "marketing.campaigns.generate_ai"}
|
||||
case "normalize", "specs", "fill", "categories", "attributes", "full", "":
|
||||
return []string{"capability.normalize_specs_fill"}
|
||||
default:
|
||||
return []string{"capability.normalize_specs_fill"}
|
||||
}
|
||||
}
|
||||
|
||||
// AssertFeatures fails closed on the first disabled key.
|
||||
// Loads Capabilities once for the whole key set (avoids N×CapabilitiesForCompany).
|
||||
func (s *Service) AssertFeatures(ctx context.Context, companyID uuid.UUID, keys ...string) error {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if caps.Features == nil || !caps.Features[key] {
|
||||
return fmt.Errorf("%w: %s", ErrFeatureDisabled, key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssertProcessingFeatures enforces plan ∩ global feature keys for a job type.
|
||||
func (s *Service) AssertProcessingFeatures(ctx context.Context, companyID uuid.UUID, processingType string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.AssertFeatures(ctx, companyID, FeatureKeysForProcessingType(processingType)...)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPlanFeaturesFreeDeniesAI(t *testing.T) {
|
||||
m := DefaultPlanFeatures("Free", false)
|
||||
for _, key := range []string{
|
||||
"capability.ai_processing",
|
||||
"catalog.products.process_ai_titles",
|
||||
"marketing.campaigns.generate_ai",
|
||||
"settings.api_keys",
|
||||
"capability.api_access",
|
||||
"capability.email_live_send",
|
||||
} {
|
||||
if m[key] {
|
||||
t.Fatalf("Free should deny %s", key)
|
||||
}
|
||||
}
|
||||
if !m["catalog.products"] || !m["capability.normalize_specs_fill"] {
|
||||
t.Fatal("Free should allow catalog + normalize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPlanFeaturesCustomEnableAll(t *testing.T) {
|
||||
m := DefaultPlanFeatures("Acme Deal", true)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !m[k] {
|
||||
t.Fatalf("custom should enable all; missing %s", k)
|
||||
}
|
||||
}
|
||||
m2 := DefaultPlanFeatures("Enterprise", true)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !m2[k] {
|
||||
t.Fatalf("Enterprise (is_custom) should enable all; missing %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveFeaturesGlobalSectionDisableAll(t *testing.T) {
|
||||
gates := emptyGatesView()
|
||||
gates.Sections["marketing"] = false
|
||||
features, sections, disabled := ResolveEffectiveFeatures("Growth", false, nil, gates)
|
||||
if sections["marketing"] {
|
||||
t.Fatal("marketing section should be off")
|
||||
}
|
||||
if features["marketing.campaigns"] || features["marketing.campaigns.generate_ai"] {
|
||||
t.Fatal("marketing keys must be false when section disabled")
|
||||
}
|
||||
found := false
|
||||
for _, d := range disabled {
|
||||
if d == "marketing.campaigns.generate_ai" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("disabled_features should list marketing.campaigns.generate_ai")
|
||||
}
|
||||
if !features["catalog.products"] {
|
||||
t.Fatal("catalog should remain on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveEffectiveFeaturesCustomOverrideFalse(t *testing.T) {
|
||||
gates := emptyGatesView()
|
||||
overrides := map[string]bool{"settings.api_keys": false}
|
||||
features, _, _ := ResolveEffectiveFeatures("Client Deal", true, overrides, gates)
|
||||
if features["settings.api_keys"] {
|
||||
t.Fatal("override false must win on custom")
|
||||
}
|
||||
if !features["capability.ai_processing"] {
|
||||
t.Fatal("other keys stay on for custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAllowsFeatureCapabilityResolution(t *testing.T) {
|
||||
if PlanAllowsFeature("Free", false, nil, "capability.ai_processing") {
|
||||
t.Fatal("Free deny AI capability")
|
||||
}
|
||||
if !PlanAllowsFeature("Starter", false, nil, "capability.ai_processing") {
|
||||
t.Fatal("Starter allow AI capability")
|
||||
}
|
||||
if PlanAllowsFeature("Starter", false, nil, "capability.byok") {
|
||||
t.Fatal("Starter deny BYOK")
|
||||
}
|
||||
if !PlanAllowsFeature("Growth", false, nil, "capability.byok") {
|
||||
t.Fatal("Growth allow BYOK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureKeyFromError(t *testing.T) {
|
||||
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
|
||||
if got := FeatureKeyFromError(err); got != "marketing.campaigns.generate_ai" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if FeatureKeyFromError(errors.New("other")) != "" {
|
||||
t.Fatal("non-feature error should yield empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeatureKeysForProcessingType(t *testing.T) {
|
||||
keys := FeatureKeysForProcessingType("title")
|
||||
if len(keys) != 2 || keys[0] != "capability.ai_processing" {
|
||||
t.Fatalf("title keys: %v", keys)
|
||||
}
|
||||
keys = FeatureKeysForProcessingType("normalize")
|
||||
if len(keys) != 1 || keys[0] != "capability.normalize_specs_fill" {
|
||||
t.Fatalf("normalize keys: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneGatesViewIndependent(t *testing.T) {
|
||||
src := emptyGatesView()
|
||||
src.Sections["marketing"] = false
|
||||
src.Features["capability.ai_processing"] = false
|
||||
dst := cloneGatesView(src)
|
||||
dst.Sections["marketing"] = true
|
||||
dst.Features["capability.ai_processing"] = true
|
||||
if src.Sections["marketing"] {
|
||||
t.Fatal("clone must not share sections map")
|
||||
}
|
||||
if src.Features["capability.ai_processing"] {
|
||||
t.Fatal("clone must not share features map")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FeatureDef is one catalog entry for listFeatures / admin editors.
|
||||
type FeatureDef struct {
|
||||
Key string `json:"key"`
|
||||
Section string `json:"section"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// ListFeatures returns the canonical feature registry (listFeatures).
|
||||
func (s *Service) ListFeatures(_ context.Context) ([]FeatureDef, error) {
|
||||
out := make([]FeatureDef, 0, len(FeatureCatalogKeys))
|
||||
for _, key := range FeatureCatalogKeys {
|
||||
section, _ := SectionOfFeature(key)
|
||||
out = append(out, FeatureDef{
|
||||
Key: key,
|
||||
Section: section,
|
||||
Label: key,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IsAllowed reports effective(feature) for a company's active plan (isAllowed).
|
||||
func (s *Service) IsAllowed(ctx context.Context, companyID uuid.UUID, key string) (bool, error) {
|
||||
caps, err := s.CapabilitiesForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if caps.Features == nil {
|
||||
return false, nil
|
||||
}
|
||||
return caps.Features[key], nil
|
||||
}
|
||||
|
||||
// IsAllowedForPlan reports effective(feature) for a plan id (globals still apply).
|
||||
func (s *Service) IsAllowedForPlan(ctx context.Context, planID int64, key string) (bool, error) {
|
||||
name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
gates, err := s.GetFeatureGates(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
features, _, _ := ResolveEffectiveFeaturesEx(name, isCustom, isLegacy, overrides, gates)
|
||||
return features[strings.TrimSpace(key)], nil
|
||||
}
|
||||
|
||||
// SetPlanFeature merges one override into plans.features (setPlanFeature).
|
||||
func (s *Service) SetPlanFeature(ctx context.Context, planID int64, key string, enabled bool) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if !IsKnownFeatureKey(key) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, key)
|
||||
}
|
||||
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if overrides == nil {
|
||||
overrides = map[string]bool{}
|
||||
}
|
||||
overrides[key] = enabled
|
||||
_, err = s.SetPlanFeatures(ctx, planID, overrides)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetGlobalFeature upserts one platform master switch (setGlobalFeature).
|
||||
// Section ids use kind=section; feature keys use kind=feature.
|
||||
func (s *Service) SetGlobalFeature(ctx context.Context, gateKey string, enabled bool, updatedBy *uuid.UUID) error {
|
||||
gateKey = strings.TrimSpace(gateKey)
|
||||
if gateKey == "" {
|
||||
return fmt.Errorf("%w: empty gate key", ErrUnknownFeatureKey)
|
||||
}
|
||||
if IsKnownFeatureSection(gateKey) {
|
||||
_, err := s.SetSectionGate(ctx, gateKey, enabled, updatedBy)
|
||||
return err
|
||||
}
|
||||
if !IsKnownFeatureKey(gateKey) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, gateKey)
|
||||
}
|
||||
_, err := s.SetFeatureGates(ctx, nil, map[string]bool{gateKey: enabled}, updatedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnableAllForPlan writes all registry keys as true overrides (enableAllForPlan).
|
||||
func (s *Service) EnableAllForPlan(ctx context.Context, planID int64) error {
|
||||
_, err := s.EnableAllPlanFeatures(ctx, planID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ApplyDefaultMatrix replaces plans.features with sparse defaults for that plan (applyDefaultMatrix).
|
||||
// Custom packages get an empty override map (is_custom => all ON at resolve).
|
||||
// Legacy packages get SparseLegacyOverrides (processing.monitor OFF; image-nav ON).
|
||||
func (s *Service) ApplyDefaultMatrix(ctx context.Context, planID int64) error {
|
||||
name, isCustom, isLegacy, _, err := s.loadPlanFeaturesRow(ctx, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.SetPlanFeatures(ctx, planID, SparseDefaultOverridesEx(name, isCustom, isLegacy))
|
||||
return err
|
||||
}
|
||||
|
||||
// AssertFeature fails closed when a feature is not effective for the company.
|
||||
func (s *Service) AssertFeature(ctx context.Context, companyID uuid.UUID, key string) error {
|
||||
ok, err := s.IsAllowed(ctx, companyID, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrFeatureDisabled, strings.TrimSpace(key))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveFeatures is the contract name for ResolveEffectiveFeatures (plan ∧ globals).
|
||||
func ResolveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
|
||||
features, _, _ := ResolveEffectiveFeatures(planName, isCustom, overrides, gates)
|
||||
return features
|
||||
}
|
||||
|
||||
// ResolveFeaturesEx includes an explicit is_legacy flag.
|
||||
func ResolveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) map[string]bool {
|
||||
features, _, _ := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates)
|
||||
return features
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultPlanFeaturesStarterBYOK(t *testing.T) {
|
||||
m := DefaultPlanFeatures("Starter", false)
|
||||
if !m["catalog.products.process_ai_titles"] {
|
||||
t.Fatal("Starter should allow AI titles")
|
||||
}
|
||||
if m["integrations.ai.byok"] || m["capability.byok"] {
|
||||
t.Fatal("Starter should deny BYOK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPlanFeaturesGrowthAllOn(t *testing.T) {
|
||||
m := DefaultPlanFeatures("Growth", false)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !m[k] {
|
||||
t.Fatalf("Growth should allow %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAllowsOverrideFalseWins(t *testing.T) {
|
||||
overrides := map[string]bool{"settings.api_keys": false}
|
||||
if PlanAllowsFeature("Growth", false, overrides, "settings.api_keys") {
|
||||
t.Fatal("override false should win on Growth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFeaturesGlobalFeatureOff(t *testing.T) {
|
||||
gates := FeatureGatesView{
|
||||
Sections: map[string]bool{},
|
||||
Features: map[string]bool{"capability.byok": false},
|
||||
}
|
||||
features := ResolveFeatures("Business", false, nil, gates)
|
||||
if features["capability.byok"] {
|
||||
t.Fatal("global feature kill-switch should win")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparseDefaultOverridesFree(t *testing.T) {
|
||||
sparse := SparseDefaultOverrides("Free", false)
|
||||
if sparse["catalog.products.process_ai_titles"] != false {
|
||||
t.Fatal("expected sparse false for AI titles")
|
||||
}
|
||||
if _, ok := sparse["catalog.products"]; ok {
|
||||
t.Fatal("ON keys should not appear in sparse overrides")
|
||||
}
|
||||
if len(SparseDefaultOverrides("Acme", true)) != 0 {
|
||||
t.Fatal("custom sparse should be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFeatureOverridesRejectsUnknown(t *testing.T) {
|
||||
err := validateFeatureOverrides(map[string]bool{"not.a.real.key": true})
|
||||
if !errors.Is(err, ErrUnknownFeatureKey) {
|
||||
t.Fatalf("want ErrUnknownFeatureKey, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFeaturesCatalogComplete(t *testing.T) {
|
||||
s := &Service{}
|
||||
list, err := s.ListFeatures(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("got %d want %d", len(list), len(FeatureCatalogKeys))
|
||||
}
|
||||
if list[0].Section == "" {
|
||||
t.Fatal("section required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertFeatureErrorWraps(t *testing.T) {
|
||||
err := fmt.Errorf("%w: %s", ErrFeatureDisabled, "marketing.campaigns.generate_ai")
|
||||
if !errors.Is(err, ErrFeatureDisabled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "marketing.campaigns.generate_ai") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGateErrorWrapping(t *testing.T) {
|
||||
creditErr := fmt.Errorf("%w: need at least %d credits (have %d)", ErrInsufficientCredits, 5, 2)
|
||||
if !errors.Is(creditErr, ErrInsufficientCredits) {
|
||||
t.Fatal("expected ErrInsufficientCredits")
|
||||
}
|
||||
if !strings.Contains(creditErr.Error(), "need at least 5") {
|
||||
t.Fatalf("unexpected message: %v", creditErr)
|
||||
}
|
||||
|
||||
limitErr := fmt.Errorf("%w: plan allows up to %d products", ErrProductLimitExceeded, 100)
|
||||
if !errors.Is(limitErr, ErrProductLimitExceeded) {
|
||||
t.Fatal("expected ErrProductLimitExceeded")
|
||||
}
|
||||
if errors.Is(limitErr, ErrInsufficientCredits) {
|
||||
t.Fatal("should not match credits error")
|
||||
}
|
||||
|
||||
aiErr := fmt.Errorf("%w — upgrade", ErrAIRequiresUpgrade)
|
||||
if !errors.Is(aiErr, ErrAIRequiresUpgrade) {
|
||||
t.Fatal("expected ErrAIRequiresUpgrade")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeEntitlements(t *testing.T) {
|
||||
free := ComputeEntitlements("Free", 0, 0, false)
|
||||
if free.CanUseAI || !free.CanUseEPREL || !free.IsFreePlan {
|
||||
t.Fatalf("free: %+v", free)
|
||||
}
|
||||
freeWithLeftover := ComputeEntitlements("Free", 0, 10, false)
|
||||
if !freeWithLeftover.CanUseAI {
|
||||
t.Fatal("leftover credits on Free should unlock AI")
|
||||
}
|
||||
growth := ComputeEntitlements("Growth", 2000, 0, false)
|
||||
if !growth.CanUseAI || !growth.CanUseEPREL || !growth.IsPaidPlan {
|
||||
t.Fatalf("growth: %+v", growth)
|
||||
}
|
||||
enterprise := ComputeEntitlements("Enterprise", EnterpriseUnlimitedCredits, EnterpriseUnlimitedCredits, false)
|
||||
if !enterprise.CanUseAI || !enterprise.CanUseEPREL || !enterprise.IsPaidPlan || enterprise.IsFreePlan {
|
||||
t.Fatalf("enterprise: %+v", enterprise)
|
||||
}
|
||||
if enterprise.MonthlyCredits != EnterpriseUnlimitedCredits || enterprise.RemainingCredits != EnterpriseUnlimitedCredits {
|
||||
t.Fatalf("enterprise credits: %+v", enterprise)
|
||||
}
|
||||
if ProcessingTypeRequiresAI("title") != true {
|
||||
t.Fatal("title requires AI")
|
||||
}
|
||||
if ProcessingTypeRequiresAI("full") {
|
||||
t.Fatal("full should auto-skip AI on Free, not hard-require")
|
||||
}
|
||||
if !ProcessingTypeRequiresEPREL("eprel_only") {
|
||||
t.Fatal("eprel_only requires EPREL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPublicPlansEnterpriseUnlimited(t *testing.T) {
|
||||
plans := defaultPublicPlans()
|
||||
var ent *Plan
|
||||
for i := range plans {
|
||||
if strings.EqualFold(plans[i].Name, "Enterprise") {
|
||||
ent = &plans[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if ent == nil {
|
||||
t.Fatal("Enterprise missing from defaultPublicPlans")
|
||||
}
|
||||
if ent.MonthlyCredits != EnterpriseUnlimitedCredits {
|
||||
t.Fatalf("monthly_credits=%d want %d", ent.MonthlyCredits, EnterpriseUnlimitedCredits)
|
||||
}
|
||||
if ent.MaxProducts != nil {
|
||||
t.Fatalf("max_products should be nil (unlimited), got %v", *ent.MaxProducts)
|
||||
}
|
||||
if !ent.IsCustom {
|
||||
t.Fatal("Enterprise should be is_custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPublicPlansFreeZeroCredits(t *testing.T) {
|
||||
plans := defaultPublicPlans()
|
||||
var free *Plan
|
||||
for i := range plans {
|
||||
if strings.EqualFold(plans[i].Name, "Free") {
|
||||
free = &plans[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if free == nil {
|
||||
t.Fatal("Free missing from defaultPublicPlans")
|
||||
}
|
||||
if free.MonthlyCredits != 0 {
|
||||
t.Fatalf("Free monthly_credits=%d want 0 (Enterprise seed must not change this)", free.MonthlyCredits)
|
||||
}
|
||||
if free.MaxProducts == nil || *free.MaxProducts != 50 {
|
||||
t.Fatalf("Free max_products=%v want 50", free.MaxProducts)
|
||||
}
|
||||
if free.IsCustom {
|
||||
t.Fatal("Free must not be is_custom")
|
||||
}
|
||||
// Enterprise packaging must not leak into Free.
|
||||
if free.MonthlyCredits == EnterpriseUnlimitedCredits {
|
||||
t.Fatal("Free must not share Enterprise credit pack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPublicPlansFiftyPercentCover(t *testing.T) {
|
||||
want := map[string]int{
|
||||
"Starter": 100,
|
||||
"Plus": 400,
|
||||
"Growth": 1_200,
|
||||
"Business": 4_000,
|
||||
"Scale": 12_000,
|
||||
}
|
||||
pctWant := map[string]int{
|
||||
"Starter": 50, "Plus": 50, "Growth": 50, "Business": 50, "Scale": 50, "Enterprise": 50,
|
||||
}
|
||||
maxWant := map[string]int{
|
||||
"Free": 50, "Starter": 100, "Plus": 400, "Growth": 1_200, "Business": 4_000, "Scale": ScaleMaxProducts,
|
||||
}
|
||||
for name, pct := range pctWant {
|
||||
if got := PlanAICoverPercent(name); got != pct {
|
||||
t.Fatalf("PlanAICoverPercent(%s)=%d want %d", name, got, pct)
|
||||
}
|
||||
}
|
||||
for name, exp := range want {
|
||||
if got := MonthlyCreditsForPlan(name, 0); got != exp {
|
||||
t.Fatalf("MonthlyCreditsForPlan(%s)=%d want %d", name, got, exp)
|
||||
}
|
||||
}
|
||||
for _, p := range defaultPublicPlans() {
|
||||
if exp, ok := want[p.Name]; ok && p.MonthlyCredits != exp {
|
||||
t.Fatalf("%s monthly_credits=%d want %d", p.Name, p.MonthlyCredits, exp)
|
||||
}
|
||||
if p.Name == "Enterprise" {
|
||||
if p.MaxProducts != nil {
|
||||
t.Fatalf("Enterprise max_products should be nil, got %v", p.MaxProducts)
|
||||
}
|
||||
continue
|
||||
}
|
||||
wantMax, ok := maxWant[p.Name]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing from maxWant", p.Name)
|
||||
}
|
||||
if p.MaxProducts == nil || *p.MaxProducts != wantMax {
|
||||
t.Fatalf("%s max_products=%v want %d", p.Name, p.MaxProducts, wantMax)
|
||||
}
|
||||
if p.Name != "Free" {
|
||||
base := CreditSKUBase(p.Name)
|
||||
if base <= 0 || base > wantMax {
|
||||
t.Fatalf("%s CreditSKUBase=%d must be in (0, MaxProducts=%d]", p.Name, base, wantMax)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Starter included AI must stay tiny vs A1 (~€300) economics.
|
||||
if want["Starter"] > 150 {
|
||||
t.Fatalf("Starter monthly credits=%d too high vs A1 positioning", want["Starter"])
|
||||
}
|
||||
if CreditSKUBase("Starter") != 100 || want["Starter"] != 100 {
|
||||
t.Fatalf("Starter base/credits: base=%d credits=%d", CreditSKUBase("Starter"), want["Starter"])
|
||||
}
|
||||
if got := PlanMaxProducts("Scale"); got == nil || *got != ScaleMaxProducts || ScaleMaxProducts >= 1_000_000 {
|
||||
t.Fatalf("Scale max_products=%v ScaleMaxProducts=%d want %d (<1M)", got, ScaleMaxProducts, ScaleMaxProducts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A1LegacyCompanyID is the MySQL company_id for A1 Slovenija (migrated dump name kept in PG).
|
||||
// Cohort remains legacy even if an older local rename used "Local Demo Co".
|
||||
const A1LegacyCompanyID = "97e1a309-3d23-4aa2-b518-8e8d7afdfec7"
|
||||
|
||||
// LegacyPlanName is the seeded package name for migrated / limited-nav tenants.
|
||||
const LegacyPlanName = "Legacy"
|
||||
|
||||
// PlanProfile is the packaging bucket used for default feature matrices.
|
||||
type PlanProfile string
|
||||
|
||||
const (
|
||||
PlanProfileFree PlanProfile = "free"
|
||||
PlanProfileStarter PlanProfile = "starter"
|
||||
PlanProfileGrowth PlanProfile = "growth"
|
||||
PlanProfileBusiness PlanProfile = "business"
|
||||
PlanProfileEnterprise PlanProfile = "enterprise"
|
||||
PlanProfileLegacy PlanProfile = "legacy"
|
||||
PlanProfileCustom PlanProfile = "custom"
|
||||
)
|
||||
|
||||
// legacyFeatureAllowlist is the ON set for the legacy (A1) matrix.
|
||||
// Source: docs/admin-roles-support/03-roles-matrix.md / .json (legacy_user).
|
||||
var legacyFeatureAllowlist = map[string]struct{}{
|
||||
"shell.navigation": {},
|
||||
"shell.command_palette": {},
|
||||
"shell.company_switcher": {},
|
||||
"shell.tutorial": {},
|
||||
"shell.account_menu": {},
|
||||
"shell.billing_recovery_banner": {},
|
||||
"dashboard.overview": {},
|
||||
"dashboard.stats": {},
|
||||
"dashboard.quick_links": {},
|
||||
"dashboard.recent_jobs": {},
|
||||
"dashboard.news_feed": {},
|
||||
"dashboard.activation_checklist": {},
|
||||
"dashboard.migrated_checklist": {},
|
||||
"dashboard.etl_gaps": {},
|
||||
"dashboard.upgrade_banners": {},
|
||||
"catalog.products": {},
|
||||
"catalog.products.tab_processed": {},
|
||||
"catalog.products.tab_needs_review": {},
|
||||
"catalog.products.tab_error": {},
|
||||
"catalog.products.tab_processing": {},
|
||||
"catalog.products.tab_unprocessed": {},
|
||||
"catalog.products.process_categories": {},
|
||||
"catalog.products.process_attributes": {},
|
||||
"catalog.products.process_ai_titles": {},
|
||||
"catalog.products.process_ai_descriptions": {},
|
||||
"catalog.products.enrichment_review": {},
|
||||
"catalog.products.export_selection": {},
|
||||
"catalog.products.upgrade_prompt": {},
|
||||
"catalog.categories": {},
|
||||
"catalog.categories.title_formula": {},
|
||||
"catalog.categories.description_formula": {},
|
||||
"catalog.attributes": {},
|
||||
"catalog.attributes.bulk_import": {},
|
||||
"catalog.standard_fields": {},
|
||||
"catalog.standard_fields.groups": {},
|
||||
"feeds.list": {},
|
||||
"feeds.add_url": {},
|
||||
"feeds.add_csv": {},
|
||||
"feeds.sync": {},
|
||||
"feeds.mapping": {},
|
||||
"feeds.mapping.select_item": {},
|
||||
"feeds.mapping.map_fields": {},
|
||||
"feeds.export_feeds": {},
|
||||
"feeds.export_feeds.create": {},
|
||||
"feeds.export_feeds.generate": {},
|
||||
"feeds.uploads": {},
|
||||
"billing.overview": {},
|
||||
"billing.customer_portal": {},
|
||||
"billing.quick_upgrade": {},
|
||||
"billing.plans_compare": {},
|
||||
"billing.checkout": {},
|
||||
"settings.profile": {},
|
||||
"settings.company": {},
|
||||
"settings.alerts": {},
|
||||
"settings.api_keys": {},
|
||||
"settings.team": {},
|
||||
"settings.team_invite": {},
|
||||
"capability.sku_cap": {},
|
||||
"capability.ai_credits": {},
|
||||
"capability.ai_processing": {},
|
||||
"capability.eprel": {},
|
||||
"capability.normalize_specs_fill": {},
|
||||
"capability.feed_source_limit": {},
|
||||
"capability.export_feed_limit": {},
|
||||
"capability.storage_limit": {},
|
||||
"capability.api_access": {},
|
||||
}
|
||||
|
||||
// IsLegacyPlanName reports whether a plan name matches the legacy cohort patterns
|
||||
// (exact "legacy", A1*, or "a1 slovenija"). See docs/admin-roles-support/03-roles-matrix.md.
|
||||
func IsLegacyPlanName(planName string) bool {
|
||||
n := strings.ToLower(strings.TrimSpace(planName))
|
||||
if n == "" {
|
||||
return false
|
||||
}
|
||||
if n == "legacy" {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(n, "a1 slovenija") {
|
||||
return true
|
||||
}
|
||||
if n == "a1" || strings.HasPrefix(n, "a1 ") || strings.HasPrefix(n, "a1-") || strings.HasPrefix(n, "a1_") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsLegacyPlan reports legacy packaging from an explicit flag and/or name patterns.
|
||||
func IsLegacyPlan(planName string, isLegacyFlag bool) bool {
|
||||
return isLegacyFlag || IsLegacyPlanName(planName)
|
||||
}
|
||||
|
||||
// IsLegacyCompanyID reports whether a remapped legacy MySQL company id is the A1 cohort.
|
||||
func IsLegacyCompanyID(legacyCompanyID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(legacyCompanyID), A1LegacyCompanyID)
|
||||
}
|
||||
|
||||
// IsA1CohortCompany reports whether a company is the migrated A1 tenant.
|
||||
// Match only immutable legacy_company_id — never mutable display names
|
||||
// (register/rename to "A1" must not grant Legacy plan privileges).
|
||||
// companyName is retained for call-site compatibility; it is ignored.
|
||||
func IsA1CohortCompany(legacyCompanyID, companyName string) bool {
|
||||
_ = companyName
|
||||
return IsLegacyCompanyID(legacyCompanyID)
|
||||
}
|
||||
|
||||
// LegacyFeatureAllowed reports whether key is ON in the legacy matrix.
|
||||
func LegacyFeatureAllowed(key string) bool {
|
||||
_, ok := legacyFeatureAllowlist[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ResolvePlanProfile maps name + flags to the default matrix bucket.
|
||||
func ResolvePlanProfile(planName string, isCustom, isLegacyFlag bool) PlanProfile {
|
||||
if IsCustomPackage(planName, isCustom) {
|
||||
norm := strings.ToLower(strings.TrimSpace(planName))
|
||||
if norm == "enterprise" {
|
||||
return PlanProfileEnterprise
|
||||
}
|
||||
return PlanProfileCustom
|
||||
}
|
||||
if IsLegacyPlan(planName, isLegacyFlag) {
|
||||
return PlanProfileLegacy
|
||||
}
|
||||
norm := strings.ToLower(strings.TrimSpace(planName))
|
||||
switch norm {
|
||||
case "", "free":
|
||||
return PlanProfileFree
|
||||
case "starter", "plus":
|
||||
return PlanProfileStarter
|
||||
case "growth":
|
||||
return PlanProfileGrowth
|
||||
case "business", "scale":
|
||||
return PlanProfileBusiness
|
||||
case "enterprise":
|
||||
return PlanProfileEnterprise
|
||||
}
|
||||
return PlanProfileFree
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package billing
|
||||
|
||||
// SparseLegacyOverrides returns false overrides for every registry key not on the legacy allow-list.
|
||||
// Storing these makes admin UIs show an explicit legacy matrix; resolve also applies DefaultPlanFeatures.
|
||||
func SparseLegacyOverrides() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !LegacyFeatureAllowed(k) {
|
||||
out[k] = false
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// featuresMapEmpty reports whether the sparse override map is unset (nil or no keys).
|
||||
func featuresMapEmpty(features map[string]bool) bool {
|
||||
return len(features) == 0
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// EnsureLegacyDefaults idempotently:
|
||||
// 1. Upserts the Legacy plan row (meters aligned with Enterprise for migrated catalogs)
|
||||
// 2. Repairs prior enable-all feature maps on legacy-named plans
|
||||
// 3. Delegates empty/flagged sparse backfill to EnsureLegacyPlanFeatureSeeds
|
||||
// 4. Assigns the Legacy plan to A1 cohort companies when missing or on a non-legacy profile
|
||||
// 5. Repairs dump-faithful A1 PAYG plan rows (is_custom, clear mistaken is_legacy)
|
||||
func (s *Service) EnsureLegacyDefaults(ctx context.Context) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
if err := s.ensureLegacyPlanRow(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repairLegacyEnableAllFeatures(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.EnsureLegacyPlanFeatureSeeds(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.assignLegacyPlanToA1Companies(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.ensureA1PaygPlanSemantics(ctx)
|
||||
}
|
||||
|
||||
// repairLegacyEnableAllFeatures rewrites full all-true maps on legacy-named plans
|
||||
// (left over from prior custom enable-all create) to SparseLegacyOverrides.
|
||||
func (s *Service) repairLegacyEnableAllFeatures(ctx context.Context) error {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
|
||||
FROM plans`)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
rows, err = s.Pool.Query(ctx, `
|
||||
SELECT id, name, false, COALESCE(features, '{}'::jsonb) FROM plans`)
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedRelation(err) || isUndefinedColumn(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
var isLegacy bool
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &name, &isLegacy, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// Only the explicit Legacy package is rewritten; A1 PAYG / other A1* deals keep features.
|
||||
if !strings.EqualFold(strings.TrimSpace(name), LegacyPlanName) {
|
||||
continue
|
||||
}
|
||||
if !IsLegacyPlan(name, isLegacy) {
|
||||
continue
|
||||
}
|
||||
overrides, err := decodeFeaturesJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if featuresMapEmpty(overrides) || !isEnableAllOverrides(overrides) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.SetPlanFeatures(ctx, id, SparseLegacyOverrides()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) ensureLegacyPlanRow(ctx context.Context) error {
|
||||
desc := "Migrated legacy package — catalog, feeds, billing & settings (no Background Tasks / stores / marketing)"
|
||||
var id int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, LegacyPlanName).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, is_legacy, term)
|
||||
VALUES ($1, $2, $3, NULL, NULL, false, true, 'monthly')`,
|
||||
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO plans (name, description, monthly_credits, yearly_credits, max_products, is_custom, term)
|
||||
VALUES ($1, $2, $3, NULL, NULL, false, 'monthly')`,
|
||||
LegacyPlanName, desc, EnterpriseUnlimitedCredits)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.seedLegacyFeaturesIfEmpty(ctx, 0, LegacyPlanName)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
|
||||
is_custom = false, is_legacy = true, term = 'monthly', updated_at = now()
|
||||
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET description = $2, monthly_credits = $3, max_products = NULL,
|
||||
is_custom = false, term = 'monthly', updated_at = now()
|
||||
WHERE id = $1`, id, desc, EnterpriseUnlimitedCredits)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.seedLegacyFeaturesIfEmpty(ctx, id, LegacyPlanName)
|
||||
}
|
||||
|
||||
func isEnableAllOverrides(overrides map[string]bool) bool {
|
||||
if len(overrides) < len(FeatureCatalogKeys) {
|
||||
return false
|
||||
}
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
v, ok := overrides[k]
|
||||
if !ok || !v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func shouldWriteLegacySparse(overrides map[string]bool) bool {
|
||||
return featuresMapEmpty(overrides) || isEnableAllOverrides(overrides)
|
||||
}
|
||||
|
||||
func (s *Service) seedLegacyFeaturesIfEmpty(ctx context.Context, planID int64, name string) error {
|
||||
if planID == 0 {
|
||||
var id int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
planID = id
|
||||
}
|
||||
_, _, _, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !shouldWriteLegacySparse(overrides) {
|
||||
return nil
|
||||
}
|
||||
_, err = s.SetPlanFeatures(ctx, planID, SparseLegacyOverrides())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) assignLegacyPlanToA1Companies(ctx context.Context) error {
|
||||
legacyID, err := s.PlanIDByName(ctx, LegacyPlanName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// One row per company using the active plan only — joining all company_plans rows
|
||||
// previously re-AssignPlan'd when an inactive Enterprise row appeared and wiped
|
||||
// migrated credit_balances (A1 dump 2500/216 → fake pack).
|
||||
// Privilege-sensitive: match ONLY immutable legacy_company_id. Mutable names
|
||||
// ("A1", "Local Demo Co", …) must never auto-AssignPlan (register/rename IDOR).
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id::text, COALESCE(c.legacy_company_id, ''), COALESCE(c.name, ''),
|
||||
COALESCE(p.name, ''), COALESCE(p.is_legacy, false),
|
||||
COALESCE(cb.total_credits, 0), COALESCE(cb.used_credits, 0)
|
||||
FROM companies c
|
||||
LEFT JOIN company_plans cp ON cp.company_id = c.id AND cp.is_active = true
|
||||
LEFT JOIN plans p ON p.id = cp.plan_id
|
||||
LEFT JOIN credit_balances cb ON cb.company_id = c.id
|
||||
WHERE lower(COALESCE(c.legacy_company_id, '')) = lower($1)`,
|
||||
A1LegacyCompanyID)
|
||||
if err != nil {
|
||||
// Fail closed when legacy_company_id is unavailable — never fall back to name match.
|
||||
if isUndefinedColumn(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
cid, legacyCID, cname, planName string
|
||||
planLegacy bool
|
||||
total, used int
|
||||
)
|
||||
if err := rows.Scan(&cid, &legacyCID, &cname, &planName, &planLegacy, &total, &used); err != nil {
|
||||
return err
|
||||
}
|
||||
if !IsA1CohortCompany(legacyCID, cname) {
|
||||
continue
|
||||
}
|
||||
if IsLegacyPlan(planName, planLegacy) {
|
||||
continue
|
||||
}
|
||||
// Dump-faithful A1 PAYG / other custom deals keep their plan + wallet.
|
||||
if strings.EqualFold(strings.TrimSpace(planName), "A1") || strings.Contains(strings.ToLower(planName), "a1") {
|
||||
continue
|
||||
}
|
||||
// Preserve migrated wallets — AssignPlan resets used_credits and total from plan monthly.
|
||||
if total > 0 || used > 0 {
|
||||
continue
|
||||
}
|
||||
companyUUID, err := uuid.Parse(cid)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if err := s.AssignPlan(ctx, companyUUID, legacyID, false, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// ensureA1PaygPlanSemantics repairs dump-faithful A1 plans:
|
||||
// is_custom=true, is_legacy=false, PAYG description, A1PaygPlanFeatures
|
||||
// (Stores + Marketing + Integrations OFF), and documents open-ended contract dates on
|
||||
// company_plans.notes when dates are null.
|
||||
func (s *Service) ensureA1PaygPlanSemantics(ctx context.Context) error {
|
||||
desc := "A1 pay-as-you-go — credits wallet, unlimited SKUs, catalog/feeds/processing/billing (Stores, Marketing, Integrations off). EPREL included on all plans."
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET
|
||||
is_custom = true,
|
||||
is_legacy = false,
|
||||
description = $1,
|
||||
updated_at = now()
|
||||
WHERE lower(name) = 'a1'
|
||||
OR lower(name) LIKE 'a1 %'
|
||||
OR lower(name) LIKE 'a1-%'
|
||||
OR lower(name) LIKE 'a1_%'
|
||||
OR lower(name) LIKE '%a1 slovenija%'`, desc)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET is_custom = true, description = $1, updated_at = now()
|
||||
WHERE lower(name) = 'a1'
|
||||
OR lower(name) LIKE 'a1 %'
|
||||
OR lower(name) LIKE 'a1-%'
|
||||
OR lower(name) LIKE 'a1_%'
|
||||
OR lower(name) LIKE '%a1 slovenija%'`, desc)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, COALESCE(features, '{}'::jsonb)
|
||||
FROM plans
|
||||
WHERE lower(name) = 'a1'
|
||||
OR lower(name) LIKE 'a1 %'
|
||||
OR lower(name) LIKE 'a1-%'
|
||||
OR lower(name) LIKE 'a1_%'
|
||||
OR lower(name) LIKE '%a1 slovenija%'`)
|
||||
if err != nil {
|
||||
if isUndefinedRelation(err) || isUndefinedColumn(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var name string
|
||||
var raw []byte
|
||||
if err := rows.Scan(&id, &name, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := decodeFeaturesJSON(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !shouldWriteA1PaygFeatures(overrides) {
|
||||
continue
|
||||
}
|
||||
if _, err := s.SetPlanFeatures(ctx, id, A1PaygPlanFeatures()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const paygNote = "PAYG: open-ended contract (no end date). Credits are consumed as used; yearly packaging is advisory."
|
||||
// Privilege-sensitive notes: match A1* plan names and/or immutable legacy_company_id only.
|
||||
// Never match mutable company display names (same isolation as assignLegacyPlanToA1Companies).
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE company_plans cp
|
||||
SET notes = CASE
|
||||
WHEN COALESCE(cp.notes, '') = '' THEN $1
|
||||
WHEN cp.notes LIKE '%' || $1 || '%' THEN cp.notes
|
||||
ELSE cp.notes || E'\n' || $1
|
||||
END,
|
||||
updated_at = now()
|
||||
FROM plans p, companies c
|
||||
WHERE cp.plan_id = p.id
|
||||
AND cp.company_id = c.id
|
||||
AND cp.is_active = true
|
||||
AND cp.contract_end_date IS NULL
|
||||
AND (
|
||||
lower(p.name) = 'a1'
|
||||
OR lower(p.name) LIKE 'a1 %'
|
||||
OR lower(p.name) LIKE 'a1-%'
|
||||
OR lower(p.name) LIKE 'a1_%'
|
||||
OR lower(p.name) LIKE '%a1 slovenija%'
|
||||
OR lower(COALESCE(c.legacy_company_id, '')) = lower($2)
|
||||
)`, paygNote, A1LegacyCompanyID)
|
||||
if err != nil && !isUndefinedColumn(err) && !isUndefinedRelation(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeLegacySparse(overrides map[string]bool) bool {
|
||||
if len(overrides) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range overrides {
|
||||
if v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldWriteA1PaygFeatures reports whether A1 plan features need hygiene to the
|
||||
// tailored PAYG matrix (Stores + Marketing + Integrations explicitly OFF).
|
||||
func shouldWriteA1PaygFeatures(overrides map[string]bool) bool {
|
||||
if featuresMapEmpty(overrides) || looksLikeLegacySparse(overrides) || isEnableAllOverrides(overrides) {
|
||||
return true
|
||||
}
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
if !A1PaygFeatureDenied(k) {
|
||||
continue
|
||||
}
|
||||
v, ok := overrides[k]
|
||||
if !ok || v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsLegacyPlanName(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"Legacy": true,
|
||||
"legacy": true,
|
||||
"A1": true,
|
||||
"A1 Slovenija": true,
|
||||
"a1-deal": true,
|
||||
"A1 Deal": true,
|
||||
"My Legacy Co": false, // exact "legacy" only — substring must not match
|
||||
"Free": false,
|
||||
"Enterprise": false,
|
||||
"Merkur": false,
|
||||
"": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := IsLegacyPlanName(in); got != want {
|
||||
t.Fatalf("IsLegacyPlanName(%q)=%v want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPlanFeaturesLegacyMatrix(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"A1", "Legacy", "A1 Slovenija"} {
|
||||
m := DefaultPlanFeatures(name, false)
|
||||
if len(m) != len(FeatureCatalogKeys) {
|
||||
t.Fatalf("%s size=%d want %d", name, len(m), len(FeatureCatalogKeys))
|
||||
}
|
||||
for _, k := range []string{
|
||||
"dashboard.overview",
|
||||
"catalog.products",
|
||||
"feeds.list",
|
||||
"feeds.export_feeds",
|
||||
"catalog.categories",
|
||||
"catalog.attributes",
|
||||
"catalog.standard_fields",
|
||||
"billing.overview",
|
||||
"settings.profile",
|
||||
"capability.ai_processing",
|
||||
"catalog.products.process_ai_titles",
|
||||
"capability.storage_limit",
|
||||
"dashboard.migrated_checklist",
|
||||
"dashboard.etl_gaps",
|
||||
} {
|
||||
if !m[k] {
|
||||
t.Fatalf("%s should allow %s", name, k)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{
|
||||
"processing.monitor",
|
||||
"stores.hub",
|
||||
"marketing.campaigns",
|
||||
"integrations.ai",
|
||||
"support.center",
|
||||
"shell.support_notifications",
|
||||
"capability.byok",
|
||||
} {
|
||||
if m[k] {
|
||||
t.Fatalf("%s should deny %s", name, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Explicit Legacy package (or is_legacy on non-custom) forces legacy matrix.
|
||||
flagged := DefaultPlanFeaturesEx("Legacy", false, true)
|
||||
if flagged["processing.monitor"] {
|
||||
t.Fatal("is_legacy flag must apply legacy matrix when not custom")
|
||||
}
|
||||
// is_custom wins over is_legacy for PAYG core, but Stores/Marketing/Integrations stay denied.
|
||||
customWins := DefaultPlanFeaturesEx("A1", true, true)
|
||||
if !customWins["processing.monitor"] {
|
||||
t.Fatal("is_custom must win over is_legacy for PAYG core features")
|
||||
}
|
||||
if customWins["stores.hub"] || customWins["marketing.campaigns"] || customWins["integrations.ai"] || customWins["integrations.email"] {
|
||||
t.Fatal("A1 PAYG must still deny Stores, Marketing, and Integrations")
|
||||
}
|
||||
if customWins["dashboard.etl_gaps"] || customWins["dashboard.store_reconnect"] || customWins["dashboard.migrated_checklist"] {
|
||||
t.Fatal("A1 PAYG must deny cutover honesty chrome (ETL gaps / reconnect / migrated checklist)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlanProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
if ResolvePlanProfile("A1", false, false) != PlanProfileLegacy {
|
||||
t.Fatal("A1 without is_custom → legacy")
|
||||
}
|
||||
if ResolvePlanProfile("A1", true, false) != PlanProfileCustom {
|
||||
t.Fatal("A1 is_custom PAYG → custom")
|
||||
}
|
||||
if ResolvePlanProfile("A1", true, true) != PlanProfileCustom {
|
||||
t.Fatal("A1 is_custom wins over is_legacy flag")
|
||||
}
|
||||
if ResolvePlanProfile("Free", false, false) != PlanProfileFree {
|
||||
t.Fatal("Free → free")
|
||||
}
|
||||
if ResolvePlanProfile("Growth", false, false) != PlanProfileGrowth {
|
||||
t.Fatal("Growth → growth")
|
||||
}
|
||||
if ResolvePlanProfile("Enterprise", true, false) != PlanProfileEnterprise {
|
||||
t.Fatal("Enterprise → enterprise")
|
||||
}
|
||||
if ResolvePlanProfile("Merkur", false, false) != PlanProfileCustom {
|
||||
t.Fatal("Merkur → custom")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLegacyCompanyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !IsLegacyCompanyID(A1LegacyCompanyID) {
|
||||
t.Fatal("A1 id should match")
|
||||
}
|
||||
if IsLegacyCompanyID("other") {
|
||||
t.Fatal("other id should not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsA1CohortCompany(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !IsA1CohortCompany(A1LegacyCompanyID, "Anything") {
|
||||
t.Fatal("legacy id must match")
|
||||
}
|
||||
// Mutable display names must never grant cohort privileges (register/rename).
|
||||
for _, name := range []string{"A1 Slovenija", "Local Demo Co", "A1", "a1", "Retail A1", "Baikal"} {
|
||||
if IsA1CohortCompany("", name) {
|
||||
t.Fatalf("name-only %q must not match", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldWriteLegacySparse(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !shouldWriteLegacySparse(nil) || !shouldWriteLegacySparse(map[string]bool{}) {
|
||||
t.Fatal("empty should write")
|
||||
}
|
||||
if !shouldWriteLegacySparse(AllRegistryFeatures(true)) {
|
||||
t.Fatal("enable-all should repair")
|
||||
}
|
||||
partial := map[string]bool{"catalog.products": false}
|
||||
if shouldWriteLegacySparse(partial) {
|
||||
t.Fatal("admin partial customization must not be wiped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// CompanyWithoutActivePlan is a tenant with no is_active company_plans row.
|
||||
type CompanyWithoutActivePlan struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
LegacyCompanyID string `json:"legacy_company_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PlanIDByName resolves a plan by case-insensitive name (lowest id wins).
|
||||
func (s *Service) PlanIDByName(ctx context.Context, name string) (int64, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return 0, ErrPlanNameRequired
|
||||
}
|
||||
var id int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM plans WHERE lower(name) = lower($1) ORDER BY id LIMIT 1`, name).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// HasActivePlan reports whether the company has an is_active company_plans row.
|
||||
func (s *Service) HasActivePlan(ctx context.Context, companyID uuid.UUID) (bool, error) {
|
||||
var has bool
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM company_plans WHERE company_id = $1 AND is_active = true
|
||||
)`, companyID).Scan(&has)
|
||||
return has, err
|
||||
}
|
||||
|
||||
// ListCompaniesWithoutActivePlan returns companies with no active plan assignment.
|
||||
// Safe read-only operator / cutover helper (never mutates).
|
||||
// Excludes the A1 cohort (legacy_company_id) — A1 plans are managed separately.
|
||||
func (s *Service) ListCompaniesWithoutActivePlan(ctx context.Context, limit, offset int) ([]CompanyWithoutActivePlan, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT c.id, c.name, c.language, COALESCE(c.legacy_company_id, ''), c.created_at
|
||||
FROM companies c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM company_plans cp
|
||||
WHERE cp.company_id = c.id AND cp.is_active = true
|
||||
)
|
||||
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($3)
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset, A1LegacyCompanyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]CompanyWithoutActivePlan, 0)
|
||||
for rows.Next() {
|
||||
var c CompanyWithoutActivePlan
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Language, &c.LegacyCompanyID, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountCompaniesWithoutActivePlan returns how many companies lack an active plan.
|
||||
// Excludes the A1 cohort (same filter as ListCompaniesWithoutActivePlan).
|
||||
func (s *Service) CountCompaniesWithoutActivePlan(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM companies c
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM company_plans cp
|
||||
WHERE cp.company_id = c.id AND cp.is_active = true
|
||||
)
|
||||
AND lower(COALESCE(c.legacy_company_id, '')) <> lower($1)`, A1LegacyCompanyID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// AssignPlanIfMissing assigns planID only when the company has no active plan.
|
||||
// Does not deactivate or replace an existing active plan (safe cutover repair).
|
||||
// Returns assigned=false when the company already has an active plan.
|
||||
func (s *Service) AssignPlanIfMissing(ctx context.Context, companyID uuid.UUID, planID int64) (assigned bool, err error) {
|
||||
has, err := s.HasActivePlan(ctx, companyID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if has {
|
||||
return false, nil
|
||||
}
|
||||
if err := s.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// AssignPlanByNameIfMissing resolves planName then AssignPlanIfMissing.
|
||||
func (s *Service) AssignPlanByNameIfMissing(ctx context.Context, companyID uuid.UUID, planName string) (assigned bool, err error) {
|
||||
planID, err := s.PlanIDByName(ctx, planName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.AssignPlanIfMissing(ctx, companyID, planID)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestPlanIDByNameRequiresName(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &Service{}
|
||||
_, err := svc.PlanIDByName(context.Background(), " ")
|
||||
if !errors.Is(err, ErrPlanNameRequired) {
|
||||
t.Fatalf("got %v, want ErrPlanNameRequired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignPlanIfMissingSkipsExisting(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
if err := svc.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
freeID, err := svc.PlanIDByName(ctx, "Free")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
starterID, err := svc.PlanIDByName(ctx, "Starter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "missing-plans-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
assigned, err := svc.AssignPlanIfMissing(ctx, companyID, freeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !assigned {
|
||||
t.Fatal("expected first assign to succeed")
|
||||
}
|
||||
|
||||
assigned, err = svc.AssignPlanIfMissing(ctx, companyID, starterID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if assigned {
|
||||
t.Fatal("must not overwrite an existing active plan")
|
||||
}
|
||||
|
||||
has, err := svc.HasActivePlan(ctx, companyID)
|
||||
if err != nil || !has {
|
||||
t.Fatalf("has active plan: has=%v err=%v", has, err)
|
||||
}
|
||||
|
||||
var planID int64
|
||||
err = pg.QueryRow(ctx, `SELECT plan_id FROM company_plans WHERE company_id = $1 AND is_active = true`, companyID).Scan(&planID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if planID != freeID {
|
||||
t.Fatalf("active plan_id=%d, want Free id=%d", planID, freeID)
|
||||
}
|
||||
|
||||
missing, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range missing {
|
||||
if c.ID == companyID {
|
||||
t.Fatal("company with active plan must not appear in without-plan list")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCompaniesWithoutActivePlanIncludesBareCompany(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "no-plan-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
found := false
|
||||
rows, err := svc.ListCompaniesWithoutActivePlan(ctx, 500, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.ID == companyID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("bare company must appear in without-plan list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsEphemeralTestPlanName reports integration-test plan rows that should stay
|
||||
// out of the admin "catalog" filter (consume-contention-*, claim-test-plan-*, multi-plan-*).
|
||||
func IsEphemeralTestPlanName(name string) bool {
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if n == "" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(n, "consume-contention-") ||
|
||||
strings.HasPrefix(n, "claim-test-plan-") ||
|
||||
strings.HasPrefix(n, "multi-plan-")
|
||||
}
|
||||
|
||||
// IsObsoleteLadderPlanName reports pre-v2 public ladder leftovers that must never
|
||||
// appear on Choose your plan (Basic / Professional / Merkur / Mini).
|
||||
func IsObsoleteLadderPlanName(name string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "basic", "professional", "mini", "merkur", "meur", "merkur trial":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// EnsurePlanCatalogHygiene soft-hides obsolete ladder leftovers and forces EPREL
|
||||
// on for every plan (public EU data — never credit-gated).
|
||||
//
|
||||
// Soft-hide: mark Basic/Professional/… as is_custom with an archived description
|
||||
// so they never look like self-serve product rows. Rows are not deleted (may be
|
||||
// referenced by history). Ephemeral test plans are left in DB but filtered in admin UI.
|
||||
func (s *Service) EnsurePlanCatalogHygiene(ctx context.Context) error {
|
||||
if s == nil || s.Pool == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET
|
||||
is_custom = true,
|
||||
description = CASE
|
||||
WHEN description IS NULL OR btrim(description) = '' THEN
|
||||
'Archived pre-v2 plan (hidden from Choose your plan)'
|
||||
WHEN description LIKE 'Archived pre-v2%' THEN description
|
||||
ELSE 'Archived pre-v2 plan (hidden from Choose your plan). ' || description
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE lower(name) IN ('basic', 'professional', 'mini', 'merkur', 'meur', 'merkur trial')
|
||||
AND is_custom = false`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Never leave an explicit capability.eprel=false override — EPREL is free on all plans.
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE plans
|
||||
SET features = features || '{"capability.eprel": true}'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE features ? 'capability.eprel'
|
||||
AND (features->>'capability.eprel') = 'false'`)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsEphemeralTestPlanName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{
|
||||
"consume-contention-abc",
|
||||
"claim-test-plan-14c023e2",
|
||||
"multi-plan-a6b5d552",
|
||||
} {
|
||||
if !IsEphemeralTestPlanName(name) {
|
||||
t.Fatalf("expected ephemeral: %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Free", "A1", "Platform Demo", "Legacy", ""} {
|
||||
if IsEphemeralTestPlanName(name) {
|
||||
t.Fatalf("expected non-ephemeral: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsObsoleteLadderPlanName(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{"Basic", "Professional", "Merkur trial", "Mini", "Meur"} {
|
||||
if !IsObsoleteLadderPlanName(name) {
|
||||
t.Fatalf("expected obsolete: %q", name)
|
||||
}
|
||||
if IsPublicProductPlan(name) {
|
||||
t.Fatalf("obsolete must not be public: %q", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Free", "Starter", "A1", "Platform Demo"} {
|
||||
if IsObsoleteLadderPlanName(name) {
|
||||
t.Fatalf("expected retained: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAllowsEPRELOnFree(t *testing.T) {
|
||||
t.Parallel()
|
||||
if !PlanAllowsFeature("Free", false, nil, "capability.eprel") {
|
||||
t.Fatal("Free must include capability.eprel")
|
||||
}
|
||||
if !PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": true}, "capability.eprel") {
|
||||
t.Fatal("explicit true override must allow EPREL")
|
||||
}
|
||||
// Explicit false is still honored at plan_allows level; hygiene clears it in DB.
|
||||
if PlanAllowsFeature("Free", false, map[string]bool{"capability.eprel": false}, "capability.eprel") {
|
||||
t.Fatal("explicit false override still wins until hygiene clears it")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownFeatureKey = errors.New("unknown feature key")
|
||||
ErrUnknownFeatureSection = errors.New("unknown feature section")
|
||||
ErrInvalidFeatureGates = errors.New("invalid feature gates payload")
|
||||
ErrFeatureDisabled = errors.New("feature_disabled")
|
||||
)
|
||||
|
||||
// FeatureGatesView is the admin global master-switch snapshot.
|
||||
type FeatureGatesView struct {
|
||||
Sections map[string]bool `json:"sections"`
|
||||
Features map[string]bool `json:"features"`
|
||||
}
|
||||
|
||||
// PlanFeaturesView is the admin per-plan feature editor payload.
|
||||
type PlanFeaturesView struct {
|
||||
PlanID int64 `json:"plan_id"`
|
||||
PlanName string `json:"plan_name"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
IsLegacy bool `json:"is_legacy"`
|
||||
Features map[string]bool `json:"features"`
|
||||
ResolvedFeatures map[string]bool `json:"resolved_features"`
|
||||
}
|
||||
|
||||
// Capabilities is the tenant-resolved plan ∩ global feature matrix.
|
||||
type Capabilities struct {
|
||||
PlanID int64 `json:"plan_id,omitempty"`
|
||||
PlanName string `json:"plan_name"`
|
||||
IsCustom bool `json:"is_custom"`
|
||||
IsLegacy bool `json:"is_legacy"`
|
||||
HasActivePlan bool `json:"has_active_plan"`
|
||||
Features map[string]bool `json:"features"`
|
||||
Sections map[string]bool `json:"sections"`
|
||||
DisabledFeatures []string `json:"disabled_features"`
|
||||
FeatureETag string `json:"feature_etag"`
|
||||
Entitlements Entitlements `json:"entitlements"`
|
||||
}
|
||||
|
||||
// FeatureGatesUpdate is the PUT /api/admin/feature-gates body.
|
||||
type FeatureGatesUpdate struct {
|
||||
Sections map[string]bool `json:"sections"`
|
||||
Features map[string]bool `json:"features"`
|
||||
}
|
||||
|
||||
// PlanFeaturesUpdate is the PUT /api/admin/plans/{id}/features body.
|
||||
// Features replaces the stored overrides object (sparse map).
|
||||
type PlanFeaturesUpdate struct {
|
||||
Features map[string]bool `json:"features"`
|
||||
}
|
||||
|
||||
// SectionGateUpdate is the PUT /api/admin/feature-gates/sections/{section} body.
|
||||
// Enabled is required (*bool) so omitting the field cannot silently disable a section.
|
||||
type SectionGateUpdate struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// DefaultPlanFeatures returns the expanded default matrix for a plan name.
|
||||
// Legacy (A1 / is_legacy patterns) uses the image-nav allow-list — not custom all-ON.
|
||||
// Custom packages (isCustom, non-legacy) default all registry keys ON.
|
||||
func DefaultPlanFeatures(planName string, isCustom bool) map[string]bool {
|
||||
return DefaultPlanFeaturesEx(planName, isCustom, IsLegacyPlanName(planName))
|
||||
}
|
||||
|
||||
// DefaultPlanFeaturesEx is DefaultPlanFeatures with an explicit is_legacy flag.
|
||||
func DefaultPlanFeaturesEx(planName string, isCustom, isLegacy bool) map[string]bool {
|
||||
out := make(map[string]bool, len(FeatureCatalogKeys))
|
||||
// Custom deals get enable-all, except A1* PAYG which keeps Stores + AI off.
|
||||
if IsCustomPackage(planName, isCustom) {
|
||||
if IsLegacyPlanName(planName) {
|
||||
return A1PaygPlanFeatures()
|
||||
}
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
out[k] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
if IsLegacyPlan(planName, isLegacy) {
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
out[k] = LegacyFeatureAllowed(k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
norm := strings.ToLower(strings.TrimSpace(planName))
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
allowed := true
|
||||
switch norm {
|
||||
case "", "free":
|
||||
allowed = !freePlanFeatureOff(k)
|
||||
case "starter", "plus":
|
||||
allowed = !starterPlanFeatureOff(k)
|
||||
default:
|
||||
// Growth / Business / Scale / named public ladder: all ON except unknown.
|
||||
allowed = true
|
||||
}
|
||||
out[k] = allowed
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PlanAllowsFeature resolves plan_allows(key) without global gates.
|
||||
func PlanAllowsFeature(planName string, isCustom bool, overrides map[string]bool, key string) bool {
|
||||
return PlanAllowsFeatureEx(planName, isCustom, IsLegacyPlanName(planName), overrides, key)
|
||||
}
|
||||
|
||||
// PlanAllowsFeatureEx is PlanAllowsFeature with an explicit is_legacy flag.
|
||||
func PlanAllowsFeatureEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, key string) bool {
|
||||
// A1 PAYG deny list always wins — stale stored matrices must not re-enable
|
||||
// Stores / Marketing / Integrations after seed hygiene expands the deny set.
|
||||
if IsCustomPackage(planName, isCustom) && IsLegacyPlanName(planName) && A1PaygFeatureDenied(key) {
|
||||
return false
|
||||
}
|
||||
if overrides != nil {
|
||||
if v, ok := overrides[key]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if IsCustomPackage(planName, isCustom) {
|
||||
if IsLegacyPlanName(planName) {
|
||||
return !A1PaygFeatureDenied(key)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if IsLegacyPlan(planName, isLegacy) {
|
||||
defaults := DefaultPlanFeaturesEx(planName, isCustom, true)
|
||||
if v, ok := defaults[key]; ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
defaults := DefaultPlanFeaturesEx(planName, false, false)
|
||||
if v, ok := defaults[key]; ok {
|
||||
return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ResolveEffectiveFeatures applies plan ∩ global section ∩ global feature.
|
||||
func ResolveEffectiveFeatures(planName string, isCustom bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) {
|
||||
return ResolveEffectiveFeaturesEx(planName, isCustom, IsLegacyPlanName(planName), overrides, gates)
|
||||
}
|
||||
|
||||
// ResolveEffectiveFeaturesEx is ResolveEffectiveFeatures with an explicit is_legacy flag.
|
||||
func ResolveEffectiveFeaturesEx(planName string, isCustom, isLegacy bool, overrides map[string]bool, gates FeatureGatesView) (features map[string]bool, sections map[string]bool, disabled []string) {
|
||||
sections = make(map[string]bool, len(FeatureSections))
|
||||
for _, s := range FeatureSections {
|
||||
enabled := true
|
||||
if gates.Sections != nil {
|
||||
if v, ok := gates.Sections[s]; ok {
|
||||
enabled = v
|
||||
}
|
||||
}
|
||||
sections[s] = enabled
|
||||
}
|
||||
features = make(map[string]bool, len(FeatureCatalogKeys))
|
||||
disabled = make([]string, 0)
|
||||
for _, key := range FeatureCatalogKeys {
|
||||
allowed := PlanAllowsFeatureEx(planName, isCustom, isLegacy, overrides, key)
|
||||
sec, _ := SectionOfFeature(key)
|
||||
if !sections[sec] {
|
||||
allowed = false
|
||||
}
|
||||
if gates.Features != nil {
|
||||
if v, ok := gates.Features[key]; ok && !v {
|
||||
allowed = false
|
||||
}
|
||||
}
|
||||
features[key] = allowed
|
||||
if !allowed {
|
||||
disabled = append(disabled, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(disabled)
|
||||
return features, sections, disabled
|
||||
}
|
||||
|
||||
func featureETag(features map[string]bool) string {
|
||||
keys := make([]string, 0, len(features))
|
||||
for k, v := range features {
|
||||
if v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
sum := sha256.Sum256([]byte(strings.Join(keys, "\n")))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// CapabilitiesResponseETag is a strong HTTP ETag for GET /api/billing/capabilities.
|
||||
// It covers the feature map plus plan identity and remaining credits so conditional
|
||||
// GETs do not skip wallet updates when only credits change.
|
||||
func CapabilitiesResponseETag(c Capabilities) string {
|
||||
raw := fmt.Sprintf("%s|p%d|r%d|%t|%s", c.FeatureETag, c.PlanID, c.Entitlements.RemainingCredits, c.HasActivePlan, c.PlanName)
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return `"` + "sha256:" + hex.EncodeToString(sum[:8]) + `"`
|
||||
}
|
||||
|
||||
func validateFeatureOverrides(features map[string]bool) error {
|
||||
if features == nil {
|
||||
return nil
|
||||
}
|
||||
for k := range features {
|
||||
if !IsKnownFeatureKey(k) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateGatesUpdate(sections, features map[string]bool) error {
|
||||
for s := range sections {
|
||||
if !IsKnownFeatureSection(s) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownFeatureSection, s)
|
||||
}
|
||||
}
|
||||
for k := range features {
|
||||
if !IsKnownFeatureKey(k) {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownFeatureKey, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeFeaturesJSON(raw []byte) (map[string]bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var m map[string]bool
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m == nil {
|
||||
m = map[string]bool{}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func encodeFeaturesJSON(m map[string]bool) ([]byte, error) {
|
||||
if m == nil {
|
||||
m = map[string]bool{}
|
||||
}
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func emptyGatesView() FeatureGatesView {
|
||||
sections := make(map[string]bool, len(FeatureSections))
|
||||
for _, s := range FeatureSections {
|
||||
sections[s] = true
|
||||
}
|
||||
return FeatureGatesView{
|
||||
Sections: sections,
|
||||
Features: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func cloneGatesView(v FeatureGatesView) FeatureGatesView {
|
||||
out := FeatureGatesView{
|
||||
Sections: make(map[string]bool, len(v.Sections)),
|
||||
Features: make(map[string]bool, len(v.Features)),
|
||||
}
|
||||
for k, enabled := range v.Sections {
|
||||
out.Sections[k] = enabled
|
||||
}
|
||||
for k, enabled := range v.Features {
|
||||
out.Features[k] = enabled
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) invalidateFeatureGatesCache() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.gatesMu.Lock()
|
||||
s.gatesCache = nil
|
||||
s.gatesCachedAt = time.Time{}
|
||||
s.gatesMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Service) storeFeatureGatesCache(view FeatureGatesView) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
copied := cloneGatesView(view)
|
||||
s.gatesMu.Lock()
|
||||
s.gatesCache = &copied
|
||||
s.gatesCachedAt = time.Now()
|
||||
s.gatesMu.Unlock()
|
||||
}
|
||||
|
||||
// GetFeatureGates returns global section/feature master switches (missing => enabled).
|
||||
func (s *Service) GetFeatureGates(ctx context.Context) (FeatureGatesView, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return emptyGatesView(), nil
|
||||
}
|
||||
s.gatesMu.RLock()
|
||||
if s.gatesCache != nil && time.Since(s.gatesCachedAt) < featureGatesCacheTTL {
|
||||
cached := cloneGatesView(*s.gatesCache)
|
||||
s.gatesMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
s.gatesMu.RUnlock()
|
||||
|
||||
view, err := s.loadFeatureGates(ctx)
|
||||
if err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
s.storeFeatureGatesCache(view)
|
||||
return cloneGatesView(view), nil
|
||||
}
|
||||
|
||||
func (s *Service) loadFeatureGates(ctx context.Context) (FeatureGatesView, error) {
|
||||
view := emptyGatesView()
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT gate_key, kind, enabled FROM platform_feature_gates`)
|
||||
if err != nil {
|
||||
// Table may not exist yet (migration pending).
|
||||
if isUndefinedRelation(err) {
|
||||
return view, nil
|
||||
}
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key, kind string
|
||||
var enabled bool
|
||||
if err := rows.Scan(&key, &kind, &enabled); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
switch kind {
|
||||
case "section":
|
||||
view.Sections[key] = enabled
|
||||
case "feature":
|
||||
view.Features[key] = enabled
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// SetFeatureGates upserts provided section/feature gates (partial). Omitted maps are left unchanged.
|
||||
func (s *Service) SetFeatureGates(ctx context.Context, sections, features map[string]bool, updatedBy *uuid.UUID) (FeatureGatesView, error) {
|
||||
if err := validateGatesUpdate(sections, features); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
if s == nil || s.Pool == nil {
|
||||
return FeatureGatesView{}, errors.New("billing service unavailable")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
upsert := func(key, kind string, enabled bool) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO platform_feature_gates (gate_key, kind, enabled, updated_at, updated_by)
|
||||
VALUES ($1, $2, $3, now(), $4)
|
||||
ON CONFLICT (gate_key) DO UPDATE SET
|
||||
kind = EXCLUDED.kind,
|
||||
enabled = EXCLUDED.enabled,
|
||||
updated_at = now(),
|
||||
updated_by = EXCLUDED.updated_by`, key, kind, enabled, updatedBy)
|
||||
return err
|
||||
}
|
||||
for k, v := range sections {
|
||||
if err := upsert(k, "section", v); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
}
|
||||
for k, v := range features {
|
||||
if err := upsert(k, "feature", v); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return FeatureGatesView{}, err
|
||||
}
|
||||
s.invalidateFeatureGatesCache()
|
||||
return s.GetFeatureGates(ctx)
|
||||
}
|
||||
|
||||
// SetSectionGate enables/disables one section for ALL plans (global master switch).
|
||||
func (s *Service) SetSectionGate(ctx context.Context, section string, enabled bool, updatedBy *uuid.UUID) (FeatureGatesView, error) {
|
||||
section = strings.TrimSpace(section)
|
||||
if !IsKnownFeatureSection(section) {
|
||||
return FeatureGatesView{}, fmt.Errorf("%w: %s", ErrUnknownFeatureSection, section)
|
||||
}
|
||||
return s.SetFeatureGates(ctx, map[string]bool{section: enabled}, nil, updatedBy)
|
||||
}
|
||||
|
||||
func (s *Service) loadPlanFeaturesRow(ctx context.Context, planID int64) (name string, isCustom bool, isLegacy bool, overrides map[string]bool, err error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return "", false, false, nil, errors.New("billing service unavailable")
|
||||
}
|
||||
var raw []byte
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT name, is_custom, COALESCE(is_legacy, false), COALESCE(features, '{}'::jsonb)
|
||||
FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &isLegacy, &raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, false, nil, ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
// Pre-migration: fall back without is_legacy and/or features.
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT name, is_custom, COALESCE(features, '{}'::jsonb)
|
||||
FROM plans WHERE id = $1`, planID).Scan(&name, &isCustom, &raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, false, nil, ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `SELECT name, is_custom FROM plans WHERE id = $1`, planID).
|
||||
Scan(&name, &isCustom)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", false, false, nil, ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, false, nil, err
|
||||
}
|
||||
return name, isCustom, IsLegacyPlanName(name), map[string]bool{}, nil
|
||||
}
|
||||
return "", false, false, nil, err
|
||||
}
|
||||
overrides, err = decodeFeaturesJSON(raw)
|
||||
if err != nil {
|
||||
return "", false, false, nil, err
|
||||
}
|
||||
return name, isCustom, IsLegacyPlanName(name), overrides, nil
|
||||
}
|
||||
return "", false, false, nil, err
|
||||
}
|
||||
overrides, err = decodeFeaturesJSON(raw)
|
||||
if err != nil {
|
||||
return "", false, false, nil, err
|
||||
}
|
||||
if !isLegacy {
|
||||
isLegacy = IsLegacyPlanName(name)
|
||||
}
|
||||
return name, isCustom, isLegacy, overrides, nil
|
||||
}
|
||||
|
||||
func planFeaturesView(planID int64, name string, isCustom, isLegacy bool, overrides map[string]bool) PlanFeaturesView {
|
||||
if overrides == nil {
|
||||
overrides = map[string]bool{}
|
||||
}
|
||||
resolved := make(map[string]bool, len(FeatureCatalogKeys))
|
||||
for _, k := range FeatureCatalogKeys {
|
||||
resolved[k] = PlanAllowsFeatureEx(name, isCustom, isLegacy, overrides, k)
|
||||
}
|
||||
return PlanFeaturesView{
|
||||
PlanID: planID,
|
||||
PlanName: name,
|
||||
IsCustom: isCustom,
|
||||
IsLegacy: IsLegacyPlan(name, isLegacy),
|
||||
Features: overrides,
|
||||
ResolvedFeatures: resolved,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPlanFeatures returns stored overrides + plan_allows resolved matrix (globals ignored).
|
||||
func (s *Service) GetPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
|
||||
name, isCustom, isLegacy, overrides, err := s.loadPlanFeaturesRow(ctx, planID)
|
||||
if err != nil {
|
||||
return PlanFeaturesView{}, err
|
||||
}
|
||||
return planFeaturesView(planID, name, isCustom, isLegacy, overrides), nil
|
||||
}
|
||||
|
||||
// SetPlanFeatures replaces the plan's features override object.
|
||||
func (s *Service) SetPlanFeatures(ctx context.Context, planID int64, features map[string]bool) (PlanFeaturesView, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return PlanFeaturesView{}, errors.New("billing service unavailable")
|
||||
}
|
||||
if features == nil {
|
||||
features = map[string]bool{}
|
||||
}
|
||||
if err := validateFeatureOverrides(features); err != nil {
|
||||
return PlanFeaturesView{}, err
|
||||
}
|
||||
raw, err := encodeFeaturesJSON(features)
|
||||
if err != nil {
|
||||
return PlanFeaturesView{}, err
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE plans SET features = $2::jsonb, updated_at = now() WHERE id = $1`, planID, raw)
|
||||
if err != nil {
|
||||
if isUndefinedColumn(err) {
|
||||
return PlanFeaturesView{}, errors.New("plans.features column missing — run migration 026_plan_features")
|
||||
}
|
||||
return PlanFeaturesView{}, err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return PlanFeaturesView{}, ErrPlanNotFound
|
||||
}
|
||||
return s.GetPlanFeatures(ctx, planID)
|
||||
}
|
||||
|
||||
// EnableAllPlanFeatures sets every registry key to true on the plan (custom packages helper).
|
||||
func (s *Service) EnableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
|
||||
return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(true))
|
||||
}
|
||||
|
||||
// DisableAllPlanFeatures sets every registry key to false on the plan.
|
||||
func (s *Service) DisableAllPlanFeatures(ctx context.Context, planID int64) (PlanFeaturesView, error) {
|
||||
return s.SetPlanFeatures(ctx, planID, AllRegistryFeatures(false))
|
||||
}
|
||||
|
||||
// CapabilitiesForCompany returns effective features for the company's active plan ∩ globals.
|
||||
func (s *Service) CapabilitiesForCompany(ctx context.Context, companyID uuid.UUID) (Capabilities, error) {
|
||||
if s == nil || s.Pool == nil {
|
||||
return Capabilities{}, errors.New("billing service unavailable")
|
||||
}
|
||||
gates, err := s.GetFeatureGates(ctx)
|
||||
if err != nil {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
|
||||
var planID int64
|
||||
var planName string
|
||||
var isCustom bool
|
||||
var isLegacy bool
|
||||
var monthly *int
|
||||
var isTrial bool
|
||||
var raw []byte
|
||||
hasPlan := false
|
||||
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT p.id, p.name, p.is_custom, COALESCE(p.is_legacy, false), p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb)
|
||||
FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
|
||||
Scan(&planID, &planName, &isCustom, &isLegacy, &monthly, &isTrial, &raw)
|
||||
if err == nil {
|
||||
hasPlan = true
|
||||
} else if errors.Is(err, pgx.ErrNoRows) {
|
||||
planName = "Free"
|
||||
raw = []byte("{}")
|
||||
} else if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial, COALESCE(p.features, '{}'::jsonb)
|
||||
FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
|
||||
Scan(&planID, &planName, &isCustom, &monthly, &isTrial, &raw)
|
||||
if err == nil {
|
||||
hasPlan = true
|
||||
isLegacy = IsLegacyPlanName(planName)
|
||||
} else if errors.Is(err, pgx.ErrNoRows) {
|
||||
planName = "Free"
|
||||
raw = []byte("{}")
|
||||
} else if isUndefinedColumn(err) {
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT p.id, p.name, p.is_custom, p.monthly_credits, cp.is_trial
|
||||
FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).
|
||||
Scan(&planID, &planName, &isCustom, &monthly, &isTrial)
|
||||
if err == nil {
|
||||
hasPlan = true
|
||||
raw = []byte("{}")
|
||||
isLegacy = IsLegacyPlanName(planName)
|
||||
} else if errors.Is(err, pgx.ErrNoRows) {
|
||||
planName = "Free"
|
||||
raw = []byte("{}")
|
||||
} else {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
} else {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
} else {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
|
||||
overrides, err := decodeFeaturesJSON(raw)
|
||||
if err != nil {
|
||||
return Capabilities{}, err
|
||||
}
|
||||
|
||||
var total, used int
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT total_credits, used_credits FROM credit_balances WHERE company_id = $1`, companyID).
|
||||
Scan(&total, &used)
|
||||
remaining := RemainingCreditsClamped(total, used)
|
||||
monthlyVal := 0
|
||||
if monthly != nil {
|
||||
monthlyVal = *monthly
|
||||
}
|
||||
ent := ComputeEntitlements(planName, monthlyVal, remaining, isTrial)
|
||||
|
||||
isLegacy = IsLegacyPlan(planName, isLegacy)
|
||||
features, sections, disabled := ResolveEffectiveFeaturesEx(planName, isCustom, isLegacy, overrides, gates)
|
||||
out := Capabilities{
|
||||
PlanName: planName,
|
||||
IsCustom: isCustom,
|
||||
IsLegacy: isLegacy,
|
||||
HasActivePlan: hasPlan,
|
||||
Features: features,
|
||||
Sections: sections,
|
||||
DisabledFeatures: disabled,
|
||||
FeatureETag: featureETag(features),
|
||||
Entitlements: ent,
|
||||
}
|
||||
if hasPlan {
|
||||
out.PlanID = planID
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isUndefinedRelation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "does not exist") && strings.Contains(msg, "platform_feature_gates")
|
||||
}
|
||||
|
||||
func isUndefinedColumn(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
missing := strings.Contains(msg, "does not exist") || strings.Contains(msg, "undefined column") || strings.Contains(msg, "undefined_column")
|
||||
if !missing {
|
||||
return false
|
||||
}
|
||||
// Postgres: column "x" of relation "y" does not exist — features or is_legacy pre-migration.
|
||||
return strings.Contains(msg, "column") || strings.Contains(msg, "features") || strings.Contains(msg, "is_legacy")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsPublicProductPlan(t *testing.T) {
|
||||
public := []string{"Free", "Starter", "Growth", "Business", "Enterprise", " free ", "GROWTH"}
|
||||
for _, name := range public {
|
||||
if !IsPublicProductPlan(name) {
|
||||
t.Fatalf("expected public: %q", name)
|
||||
}
|
||||
}
|
||||
hidden := []string{"A1", "Merkur trial", "Merkur", "Meur", "Basic", "Professional", "Mini", ""}
|
||||
for _, name := range hidden {
|
||||
if IsPublicProductPlan(name) {
|
||||
t.Fatalf("expected hidden from public pricing: %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPublicPlans(t *testing.T) {
|
||||
all := []Plan{
|
||||
{Name: "Basic"},
|
||||
{Name: "A1", IsCustom: true},
|
||||
{Name: "Merkur trial", IsCustom: true},
|
||||
{Name: "Free"},
|
||||
{Name: "Starter"},
|
||||
{Name: "Growth"},
|
||||
{Name: "Business"},
|
||||
{Name: "Enterprise", IsCustom: true},
|
||||
{Name: "Professional"},
|
||||
}
|
||||
out := make([]Plan, 0, 5)
|
||||
for _, p := range all {
|
||||
if IsPublicProductPlan(p.Name) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) != 5 {
|
||||
t.Fatalf("got %d public plans, want 5: %+v", len(out), out)
|
||||
}
|
||||
for _, p := range out {
|
||||
key := strings.ToLower(p.Name)
|
||||
switch key {
|
||||
case "free", "starter", "growth", "business", "enterprise":
|
||||
default:
|
||||
t.Fatalf("unexpected public plan %q", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,259 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Close() })
|
||||
return pg, ctx, cancel
|
||||
}
|
||||
|
||||
func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID {
|
||||
t.Helper()
|
||||
companyID := uuid.New()
|
||||
_, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
return companyID
|
||||
}
|
||||
|
||||
func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int {
|
||||
t.Helper()
|
||||
var total int
|
||||
err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string {
|
||||
t.Helper()
|
||||
var name string
|
||||
err := pg.QueryRow(ctx, `
|
||||
SELECT lower(p.name) FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||||
}
|
||||
res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock || !res.Applied {
|
||||
t.Fatalf("expected mock applied checkout, got %#v", res)
|
||||
}
|
||||
if activePlanName(t, pg, ctx, companyID) != "starter" {
|
||||
t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID))
|
||||
}
|
||||
want := MonthlyCreditsForPlan("Starter", 0)
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != want {
|
||||
t.Fatalf("credits=%d want %d", got, want)
|
||||
}
|
||||
var subID *string
|
||||
_ = pg.QueryRow(ctx, `
|
||||
SELECT stripe_subscription_id FROM company_plans
|
||||
WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID)
|
||||
if subID == nil || *subID == "" {
|
||||
t.Fatal("mock checkout must set stripe_subscription_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockCreditPackCheckoutGrants(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||||
}
|
||||
before := creditTotal(t, pg, ctx, companyID)
|
||||
res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock || !res.Applied {
|
||||
t.Fatalf("expected mock applied pack, got %#v", res)
|
||||
}
|
||||
pack, _ := CreditPackByID("small")
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||||
t.Fatalf("credits=%d want %d", got, before+pack.Credits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret
|
||||
}
|
||||
|
||||
eventID := "evt_mock_credit_" + companyID.String()[:8]
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": eventID,
|
||||
"type": "checkout.session.completed",
|
||||
"data": map[string]any{
|
||||
"object": map[string]any{
|
||||
"id": "cs_mock_1",
|
||||
"client_reference_id": companyID.String(),
|
||||
"metadata": map[string]string{
|
||||
"kind": "credit_pack",
|
||||
"pack": "tiny",
|
||||
"credits": "9999", // must be ignored for catalog pack
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := creditTotal(t, pg, ctx, companyID)
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pack, _ := CreditPackByID("tiny")
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||||
t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits)
|
||||
}
|
||||
mid := creditTotal(t, pg, ctx, companyID)
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != mid {
|
||||
t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true},
|
||||
}
|
||||
if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activePlanName(t, pg, ctx, companyID) != "plus" {
|
||||
t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID))
|
||||
}
|
||||
|
||||
eventID := "evt_mock_del_" + companyID.String()[:8]
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": eventID,
|
||||
"type": "customer.subscription.deleted",
|
||||
"data": map[string]any{
|
||||
"object": map[string]any{
|
||||
"id": "sub_mock_del",
|
||||
"customer": "cus_mock",
|
||||
"status": "canceled",
|
||||
"metadata": map[string]string{"company_id": companyID.String()},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := activePlanName(t, pg, ctx, companyID); got != "free" {
|
||||
t.Fatalf("after delete plan=%q want free", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
secret := "whsec_mock_local"
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: &Service{Pool: pg},
|
||||
Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret},
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": "evt_signed_" + companyID.String()[:8],
|
||||
"type": "ping",
|
||||
"data": map[string]any{"object": map[string]any{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err == nil {
|
||||
t.Fatal("unsigned must fail when webhook secret set")
|
||||
}
|
||||
sig := signStripePayload(t, secret, payload)
|
||||
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||||
t.Fatalf("valid signature under ForceMock: %v", err)
|
||||
}
|
||||
// Second delivery is an idempotent no-op.
|
||||
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`,
|
||||
fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("claim rows=%d want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SalesQuoteCheckoutInput drives Checkout for an admin-prepared custom deal.
|
||||
type SalesQuoteCheckoutInput struct {
|
||||
QuoteID uuid.UUID
|
||||
CompanyID uuid.UUID
|
||||
PlanID int64
|
||||
PlanName string
|
||||
Email string
|
||||
CompanyName string
|
||||
Currency string
|
||||
TotalAmountCents int
|
||||
InstallmentCount int
|
||||
InstallmentInterval string // month | quarter | year
|
||||
InstallmentAmountCents int
|
||||
}
|
||||
|
||||
// SalesQuoteCheckoutResult is returned to admin after preparing Checkout for a quote.
|
||||
type SalesQuoteCheckoutResult struct {
|
||||
URL string `json:"url"`
|
||||
Mock bool `json:"mock"`
|
||||
Applied bool `json:"applied,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ProductID string `json:"product_id,omitempty"`
|
||||
PriceID string `json:"price_id,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateSalesQuoteCheckout creates a Stripe Price + Checkout Session for a sales quote.
|
||||
//
|
||||
// ASSUMPTION (installments):
|
||||
// - installment_count == 1 → Checkout mode=payment (one-time Price).
|
||||
// - installment_count > 1 → Checkout mode=subscription with a recurring Price equal to
|
||||
// installment_amount_cents; subscription_data.cancel_at ends billing after N intervals
|
||||
// (month / quarter=3 months / year). Stripe collects the first installment at Checkout;
|
||||
// later invoices are charged automatically on the subscription.
|
||||
func (s *StripeService) CreateSalesQuoteCheckout(ctx context.Context, in SalesQuoteCheckoutInput) (SalesQuoteCheckoutResult, error) {
|
||||
if in.QuoteID == uuid.Nil || in.CompanyID == uuid.Nil || in.PlanID <= 0 {
|
||||
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: quote identifiers", ErrStripePlanUnsupported)
|
||||
}
|
||||
if in.InstallmentAmountCents <= 0 || in.TotalAmountCents <= 0 {
|
||||
return SalesQuoteCheckoutResult{}, fmt.Errorf("%w: amounts", ErrStripePlanUnsupported)
|
||||
}
|
||||
count := in.InstallmentCount
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
interval := strings.ToLower(strings.TrimSpace(in.InstallmentInterval))
|
||||
if interval == "" {
|
||||
interval = "month"
|
||||
}
|
||||
currency := strings.ToLower(strings.TrimSpace(in.Currency))
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
|
||||
ctx, cfg, err := s.bindCfg(ctx)
|
||||
if err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
web := strings.TrimRight(cfg.WebOrigin, "/")
|
||||
if web == "" {
|
||||
web = "http://localhost:5174"
|
||||
}
|
||||
|
||||
if cfg.AllowMockPurchase() {
|
||||
if err := s.applySalesQuotePurchase(ctx, in.CompanyID, in.PlanID, in.QuoteID, "cus_mock_"+in.CompanyID.String()[:8], "sub_mock_"+uuid.NewString()[:8], "price_mock_quote"); err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
return SalesQuoteCheckoutResult{
|
||||
URL: web + "/billing?checkout=success&mock=1&sales_quote=" + url.QueryEscape(in.QuoteID.String()),
|
||||
Mock: true,
|
||||
Applied: true,
|
||||
Message: "Mock mode: custom sales quote plan assigned without Stripe.",
|
||||
}, nil
|
||||
}
|
||||
if cfg.MockMode() {
|
||||
return SalesQuoteCheckoutResult{}, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
productID, err := s.ensureSalesQuoteProduct(ctx, in)
|
||||
if err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
priceID, err := s.createSalesQuotePrice(ctx, productID, in, count == 1)
|
||||
if err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
|
||||
customerID, err := s.ensureCustomer(ctx, in.CompanyID, in.Email, in.CompanyName)
|
||||
if err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("success_url", web+"/billing?checkout=success&sales_quote="+url.QueryEscape(in.QuoteID.String()))
|
||||
form.Set("cancel_url", web+"/billing?checkout=cancel&sales_quote="+url.QueryEscape(in.QuoteID.String()))
|
||||
form.Set("client_reference_id", in.CompanyID.String())
|
||||
form.Set("metadata[company_id]", in.CompanyID.String())
|
||||
form.Set("metadata[kind]", "sales_quote")
|
||||
form.Set("metadata[quote_id]", in.QuoteID.String())
|
||||
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
||||
form.Set("metadata[plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
|
||||
form.Set("metadata[installment_count]", strconv.Itoa(count))
|
||||
form.Set("metadata[installment_interval]", interval)
|
||||
form.Set("line_items[0][price]", priceID)
|
||||
form.Set("line_items[0][quantity]", "1")
|
||||
form.Set("allow_promotion_codes", "true")
|
||||
if customerID != "" {
|
||||
form.Set("customer", customerID)
|
||||
} else if in.Email != "" {
|
||||
form.Set("customer_email", in.Email)
|
||||
}
|
||||
|
||||
if count == 1 {
|
||||
form.Set("mode", "payment")
|
||||
form.Set("payment_intent_data[metadata][company_id]", in.CompanyID.String())
|
||||
form.Set("payment_intent_data[metadata][kind]", "sales_quote")
|
||||
form.Set("payment_intent_data[metadata][quote_id]", in.QuoteID.String())
|
||||
form.Set("payment_intent_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
|
||||
} else {
|
||||
form.Set("mode", "subscription")
|
||||
form.Set("subscription_data[metadata][company_id]", in.CompanyID.String())
|
||||
form.Set("subscription_data[metadata][kind]", "sales_quote")
|
||||
form.Set("subscription_data[metadata][quote_id]", in.QuoteID.String())
|
||||
form.Set("subscription_data[metadata][plan_id]", strconv.FormatInt(in.PlanID, 10))
|
||||
form.Set("subscription_data[metadata][plan]", strings.ToLower(strings.TrimSpace(in.PlanName)))
|
||||
cancelAt, err := salesQuoteCancelAt(time.Now().UTC(), count, interval)
|
||||
if err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
form.Set("subscription_data[cancel_at]", strconv.FormatInt(cancelAt.Unix(), 10))
|
||||
}
|
||||
|
||||
var sess struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/checkout/sessions", form, &sess); err != nil {
|
||||
return SalesQuoteCheckoutResult{}, err
|
||||
}
|
||||
if sess.URL == "" {
|
||||
return SalesQuoteCheckoutResult{}, errors.New("stripe checkout session missing url")
|
||||
}
|
||||
return SalesQuoteCheckoutResult{
|
||||
URL: sess.URL,
|
||||
Mock: false,
|
||||
ProductID: productID,
|
||||
PriceID: priceID,
|
||||
SessionID: sess.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StripeService) ensureSalesQuoteProduct(ctx context.Context, in SalesQuoteCheckoutInput) (string, error) {
|
||||
metaKey := in.QuoteID.String()
|
||||
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_sales_quote']:'%s'", metaKey))
|
||||
var search struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
|
||||
return search.Data[0].ID, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
name := strings.TrimSpace(in.PlanName)
|
||||
if name == "" {
|
||||
name = "Custom Descrybe plan"
|
||||
}
|
||||
form.Set("name", "Descrybe — "+name)
|
||||
form.Set("description", fmt.Sprintf("Sales quote %s (%d installments)", in.QuoteID.String(), in.InstallmentCount))
|
||||
form.Set("metadata[descrybe_sales_quote]", metaKey)
|
||||
form.Set("metadata[company_id]", in.CompanyID.String())
|
||||
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
||||
var product struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(product.ID) == "" {
|
||||
return "", fmt.Errorf("stripe product missing id")
|
||||
}
|
||||
return product.ID, nil
|
||||
}
|
||||
|
||||
func (s *StripeService) createSalesQuotePrice(ctx context.Context, productID string, in SalesQuoteCheckoutInput, oneTime bool) (string, error) {
|
||||
form := url.Values{}
|
||||
form.Set("product", productID)
|
||||
form.Set("currency", strings.ToLower(strings.TrimSpace(in.Currency)))
|
||||
if form.Get("currency") == "" {
|
||||
form.Set("currency", "usd")
|
||||
}
|
||||
form.Set("unit_amount", strconv.Itoa(in.InstallmentAmountCents))
|
||||
form.Set("metadata[descrybe_sales_quote]", in.QuoteID.String())
|
||||
form.Set("metadata[plan_id]", strconv.FormatInt(in.PlanID, 10))
|
||||
if oneTime {
|
||||
// default type one_time
|
||||
} else {
|
||||
stripeInterval, intervalCount, err := stripeRecurringFromInstallment(in.InstallmentInterval)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form.Set("recurring[interval]", stripeInterval)
|
||||
if intervalCount > 1 {
|
||||
form.Set("recurring[interval_count]", strconv.Itoa(intervalCount))
|
||||
}
|
||||
}
|
||||
var price struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(price.ID) == "" {
|
||||
return "", fmt.Errorf("stripe price missing id")
|
||||
}
|
||||
return price.ID, nil
|
||||
}
|
||||
|
||||
func stripeRecurringFromInstallment(interval string) (stripeInterval string, intervalCount int, err error) {
|
||||
switch strings.ToLower(strings.TrimSpace(interval)) {
|
||||
case "month", "":
|
||||
return "month", 1, nil
|
||||
case "quarter":
|
||||
return "month", 3, nil
|
||||
case "year":
|
||||
return "year", 1, nil
|
||||
default:
|
||||
return "", 0, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func salesQuoteCancelAt(now time.Time, count int, interval string) (time.Time, error) {
|
||||
if count < 1 {
|
||||
return time.Time{}, fmt.Errorf("%w: installment count", ErrStripePlanUnsupported)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(interval)) {
|
||||
case "month", "":
|
||||
return now.AddDate(0, count, 0), nil
|
||||
case "quarter":
|
||||
return now.AddDate(0, count*3, 0), nil
|
||||
case "year":
|
||||
return now.AddDate(count, 0, 0), nil
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("%w: installment interval %q", ErrStripePlanUnsupported, interval)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StripeService) applySalesQuotePurchase(ctx context.Context, companyID uuid.UUID, planID int64, quoteID uuid.UUID, customerID, subscriptionID, priceID string) error {
|
||||
if s.Billing == nil {
|
||||
return errors.New("billing service not configured")
|
||||
}
|
||||
if planID <= 0 {
|
||||
return fmt.Errorf("%w: plan_id", ErrStripePlanUnsupported)
|
||||
}
|
||||
if err := s.Billing.AssignPlan(ctx, companyID, planID, false, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if customerID != "" {
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE companies SET stripe_customer_id = $2, updated_at = now() WHERE id = $1`,
|
||||
companyID, customerID)
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE company_plans
|
||||
SET stripe_subscription_id = NULLIF($2, ''), stripe_price_id = NULLIF($3, ''), updated_at = now()
|
||||
WHERE company_id = $1 AND is_active = true`,
|
||||
companyID, subscriptionID, priceID)
|
||||
_ = s.setSubscriptionStatusNote(ctx, companyID, "active")
|
||||
|
||||
if quoteID != uuid.Nil {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE sales_quotes
|
||||
SET status = 'paid', paid_at = COALESCE(paid_at, now()), updated_at = now()
|
||||
WHERE id = $1 AND status <> 'canceled'`, quoteID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE sales_leads
|
||||
SET status = 'won', updated_at = now()
|
||||
WHERE id = (SELECT lead_id FROM sales_quotes WHERE id = $1)
|
||||
AND status <> 'closed'`, quoteID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSalesQuoteCancelAt(t *testing.T) {
|
||||
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
|
||||
got, err := salesQuoteCancelAt(now, 4, "month")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 12, 8, 12, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("month cancel_at = %v, want %v", got, want)
|
||||
}
|
||||
got, err = salesQuoteCancelAt(now, 2, "quarter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = time.Date(2027, 2, 8, 12, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("quarter cancel_at = %v, want %v", got, want)
|
||||
}
|
||||
got, err = salesQuoteCancelAt(now, 1, "year")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = time.Date(2027, 8, 8, 12, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("year cancel_at = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeRecurringFromInstallment(t *testing.T) {
|
||||
iv, n, err := stripeRecurringFromInstallment("quarter")
|
||||
if err != nil || iv != "month" || n != 3 {
|
||||
t.Fatalf("quarter => %s/%d err=%v", iv, n, err)
|
||||
}
|
||||
iv, n, err = stripeRecurringFromInstallment("month")
|
||||
if err != nil || iv != "month" || n != 1 {
|
||||
t.Fatalf("month => %s/%d err=%v", iv, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SyncCreditPackResult is one pack after Stripe Product/Price ensure.
|
||||
type SyncCreditPackResult struct {
|
||||
PackID string `json:"pack_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
PriceID string `json:"price_id"`
|
||||
Created bool `json:"created"`
|
||||
Credits int `json:"credits"`
|
||||
PriceUSD int `json:"price_usd"`
|
||||
}
|
||||
|
||||
// SyncCreditPackProducts creates/updates Stripe Products + one-time Prices for
|
||||
// DefaultCreditPacks. Packs are additional one-time products (Checkout mode=payment),
|
||||
// not subscription add-ons. Metadata descrybe_pack=<id> identifies each product.
|
||||
func (s *StripeService) SyncCreditPackProducts(ctx context.Context) ([]SyncCreditPackResult, error) {
|
||||
ctx, cfg, err := s.bindCfg(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.MockMode() || strings.TrimSpace(cfg.SecretKey) == "" {
|
||||
return nil, ErrStripeNotConfigured
|
||||
}
|
||||
|
||||
out := make([]SyncCreditPackResult, 0, len(DefaultCreditPacks()))
|
||||
for _, pack := range DefaultCreditPacks() {
|
||||
productID, createdProduct, err := s.ensureCreditPackProduct(ctx, pack)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("pack %s product: %w", pack.ID, err)
|
||||
}
|
||||
priceID, createdPrice, err := s.ensureCreditPackPrice(ctx, productID, pack)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("pack %s price: %w", pack.ID, err)
|
||||
}
|
||||
out = append(out, SyncCreditPackResult{
|
||||
PackID: pack.ID,
|
||||
ProductID: productID,
|
||||
PriceID: priceID,
|
||||
Created: createdProduct || createdPrice,
|
||||
Credits: pack.Credits,
|
||||
PriceUSD: pack.PriceUSD,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *StripeService) ensureCreditPackProduct(ctx context.Context, pack CreditPack) (productID string, created bool, err error) {
|
||||
q := url.QueryEscape(fmt.Sprintf("active:'true' AND metadata['descrybe_pack']:'%s'", pack.ID))
|
||||
var search struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := s.stripeGET(ctx, "https://api.stripe.com/v1/products/search?query="+q+"&limit=1", &search); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(search.Data) > 0 && strings.TrimSpace(search.Data[0].ID) != "" {
|
||||
return search.Data[0].ID, false, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("name", "Descrybe AI credits — "+pack.Name)
|
||||
form.Set("description", pack.Description)
|
||||
form.Set("metadata[descrybe_pack]", pack.ID)
|
||||
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
|
||||
form.Set("metadata[ai_products]", strconv.Itoa(pack.AIProducts))
|
||||
form.Set("metadata[price_usd]", strconv.Itoa(pack.PriceUSD))
|
||||
var product struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/products", form, &product); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if strings.TrimSpace(product.ID) == "" {
|
||||
return "", false, fmt.Errorf("stripe product missing id")
|
||||
}
|
||||
return product.ID, true, nil
|
||||
}
|
||||
|
||||
func (s *StripeService) ensureCreditPackPrice(ctx context.Context, productID string, pack CreditPack) (priceID string, created bool, err error) {
|
||||
// Reuse an active one-time price on this product that matches unit amount.
|
||||
wantCents := pack.PriceUSD * 100
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
UnitAmount int64 `json:"unit_amount"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
Active bool `json:"active"`
|
||||
} `json:"data"`
|
||||
}
|
||||
endpoint := "https://api.stripe.com/v1/prices?product=" + url.QueryEscape(productID) + "&active=true&limit=20"
|
||||
if err := s.stripeGET(ctx, endpoint, &list); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
for _, p := range list.Data {
|
||||
if p.Active && p.Type == "one_time" && strings.EqualFold(p.Currency, "usd") && int(p.UnitAmount) == wantCents {
|
||||
return p.ID, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("product", productID)
|
||||
form.Set("currency", "usd")
|
||||
form.Set("unit_amount", strconv.Itoa(wantCents))
|
||||
form.Set("metadata[descrybe_pack]", pack.ID)
|
||||
form.Set("metadata[credits]", strconv.Itoa(pack.Credits))
|
||||
var price struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.stripeForm(ctx, http.MethodPost, "https://api.stripe.com/v1/prices", form, &price); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if strings.TrimSpace(price.ID) == "" {
|
||||
return "", false, fmt.Errorf("stripe price missing id")
|
||||
}
|
||||
return price.ID, true, nil
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestNormalizePlanTerm(t *testing.T) {
|
||||
plan, term, err := normalizePlanTerm("Starter", "YEARLY")
|
||||
if err != nil || plan != "starter" || term != "yearly" {
|
||||
t.Fatalf("got %s %s err=%v", plan, term, err)
|
||||
}
|
||||
plan, term, err = normalizePlanTerm("Plus", "monthly")
|
||||
if err != nil || plan != "plus" || term != "monthly" {
|
||||
t.Fatalf("plus: got %s %s err=%v", plan, term, err)
|
||||
}
|
||||
plan, term, err = normalizePlanTerm("scale", "yearly")
|
||||
if err != nil || plan != "scale" || term != "yearly" {
|
||||
t.Fatalf("scale: got %s %s err=%v", plan, term, err)
|
||||
}
|
||||
_, _, err = normalizePlanTerm("enterprise", "monthly")
|
||||
if err == nil {
|
||||
t.Fatal("enterprise must be rejected")
|
||||
}
|
||||
_, _, err = normalizePlanTerm("free", "monthly")
|
||||
if err == nil {
|
||||
t.Fatal("free must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutReturnURL(t *testing.T) {
|
||||
success := checkoutReturnURL("https://app.example", "success", "starter", "monthly", "", true)
|
||||
if success != "https://app.example/billing?checkout=success&plan=starter&term=monthly&session_id={CHECKOUT_SESSION_ID}" {
|
||||
t.Fatalf("success url: %s", success)
|
||||
}
|
||||
cancel := checkoutReturnURL("https://app.example/", "cancel", "starter", "yearly", "", false)
|
||||
if cancel != "https://app.example/billing?checkout=cancel&plan=starter&term=yearly" {
|
||||
t.Fatalf("cancel url: %s", cancel)
|
||||
}
|
||||
pack := checkoutReturnURL("https://app.example", "success", "", "", "tiny", true)
|
||||
if pack != "https://app.example/billing?checkout=success&pack=tiny&session_id={CHECKOUT_SESSION_ID}" {
|
||||
t.Fatalf("pack url: %s", pack)
|
||||
}
|
||||
packCancel := checkoutReturnURL("https://app.example", "cancel", "", "", "tiny", false)
|
||||
if packCancel != "https://app.example/billing?checkout=cancel&pack=tiny" {
|
||||
t.Fatalf("pack cancel url: %s", packCancel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyStripeSignature(t *testing.T) {
|
||||
secret := "whsec_test_secret"
|
||||
payload := []byte(`{"id":"evt_1","type":"checkout.session.completed"}`)
|
||||
ts := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = fmt.Fprintf(mac, "%d.", ts)
|
||||
_, _ = mac.Write(payload)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
header := fmt.Sprintf("t=%d,v1=%s", ts, sig)
|
||||
if err := verifyStripeSignature(payload, header, secret, 5*time.Minute); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyStripeSignature(payload, "t="+fmt.Sprint(ts)+",v1=deadbeef", secret, 5*time.Minute); err == nil {
|
||||
t.Fatal("expected bad signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripeConfigMockMode(t *testing.T) {
|
||||
if !(StripeConfig{}).MockMode() {
|
||||
t.Fatal("empty secret should be mock")
|
||||
}
|
||||
if (StripeConfig{SecretKey: "sk_test_x"}).MockMode() {
|
||||
t.Fatal("secret set should not mock")
|
||||
}
|
||||
if !(StripeConfig{SecretKey: "sk_test_x", ForceMock: true}).MockMode() {
|
||||
t.Fatal("ForceMock should override")
|
||||
}
|
||||
if (StripeConfig{}).AllowMockPurchase() {
|
||||
t.Fatal("empty secret alone must not allow mock purchase")
|
||||
}
|
||||
if (StripeConfig{SecretKey: "sk_test_x"}).AllowMockPurchase() {
|
||||
t.Fatal("live secret must not allow mock purchase")
|
||||
}
|
||||
if !(StripeConfig{ForceMock: true}).AllowMockPurchase() {
|
||||
t.Fatal("ForceMock should allow mock purchase")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutSessionFailsClosedWithoutSecret(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
|
||||
_, err := s.CreateCheckoutSession(context.TODO(), uuid.Nil, "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||||
if !errors.Is(err, ErrStripeNotConfigured) {
|
||||
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebhookRejectsUnsignedWithoutForceMock(t *testing.T) {
|
||||
// Empty secret (MockMode) without ForceMock must still reject unsigned webhooks.
|
||||
s := &StripeService{Cfg: StripeConfig{}}
|
||||
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_x","type":"ping"}`), "")
|
||||
if err != ErrStripeNotConfigured {
|
||||
t.Fatalf("want ErrStripeNotConfigured, got %v", err)
|
||||
}
|
||||
s2 := &StripeService{Cfg: StripeConfig{SecretKey: "sk_test_x"}}
|
||||
err = s2.HandleWebhook(context.TODO(), []byte(`{"id":"evt_y","type":"ping"}`), "")
|
||||
if err != ErrStripeNotConfigured {
|
||||
t.Fatalf("live without webhook secret: want ErrStripeNotConfigured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebhookVerifiesEvenWhenForceMock(t *testing.T) {
|
||||
secret := "whsec_test_secret"
|
||||
payload := []byte(`{"id":"evt_1","type":"ping"}`)
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret}}
|
||||
err := s.HandleWebhook(context.TODO(), payload, "t=1,v1=deadbeef")
|
||||
if !errors.Is(err, ErrStripeBadSignature) {
|
||||
t.Fatalf("ForceMock must still verify when WebhookSecret set, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebhookRejectsUnsignedInProductionEvenWithForceMock(t *testing.T) {
|
||||
t.Setenv("APP_ENV", "production")
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
|
||||
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_prod","type":"ping"}`), "")
|
||||
if !errors.Is(err, ErrStripeNotConfigured) {
|
||||
t.Fatalf("production must reject unsigned ForceMock webhooks, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStripePriceIDs(t *testing.T) {
|
||||
m := LoadStripePriceIDs(func(k string) string {
|
||||
switch k {
|
||||
case "STRIPE_PRICE_STARTER_MONTHLY":
|
||||
return "price_starter_m"
|
||||
case "STRIPE_PRICE_PACK_SMALL":
|
||||
return "price_pack_s"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
})
|
||||
if m["starter:monthly"] != "price_starter_m" {
|
||||
t.Fatalf("got %#v", m)
|
||||
}
|
||||
if m["pack:small"] != "price_pack_s" {
|
||||
t.Fatalf("pack missing: %#v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFromPriceIDIgnoresPacks(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
|
||||
"growth:monthly": "price_g_m",
|
||||
"pack:small": "price_pack_s",
|
||||
}}}
|
||||
if got := s.planFromPriceID("price_g_m"); got != "growth" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if got := s.planFromPriceID("price_pack_s"); got != "" {
|
||||
t.Fatalf("pack price must not map to a plan, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditPackCatalog(t *testing.T) {
|
||||
if got := MonthlyCreditsForPlan("Starter", 0); got != 100 {
|
||||
t.Fatalf("starter cover: got %d", got)
|
||||
}
|
||||
if got := MonthlyCreditsForPlan("Plus", 0); got != 400 {
|
||||
t.Fatalf("plus cover: got %d", got)
|
||||
}
|
||||
if got := MonthlyCreditsForPlan("Growth", 0); got != 1200 {
|
||||
t.Fatalf("growth cover: got %d", got)
|
||||
}
|
||||
if got := MonthlyCreditsForPlan("Business", 0); got != 4000 {
|
||||
t.Fatalf("business cover: got %d", got)
|
||||
}
|
||||
if got := MonthlyCreditsForPlan("Scale", 0); got != 12000 {
|
||||
t.Fatalf("scale cover: got %d", got)
|
||||
}
|
||||
packs := DefaultCreditPacks()
|
||||
if len(packs) < 7 {
|
||||
t.Fatalf("want at least 7 packs, got %d", len(packs))
|
||||
}
|
||||
tiny, ok := CreditPackByID("tiny")
|
||||
if !ok || tiny.Credits != 25 || tiny.PriceUSD != 29 {
|
||||
t.Fatalf("tiny pack: %#v ok=%v", tiny, ok)
|
||||
}
|
||||
small, ok := CreditPackByID("small")
|
||||
if !ok || small.Credits != 65 || small.PriceUSD != 59 {
|
||||
t.Fatalf("small pack: %#v ok=%v", small, ok)
|
||||
}
|
||||
med, ok := CreditPackByID("medium")
|
||||
if !ok || med.Credits != 200 || med.PriceUSD != 149 {
|
||||
t.Fatalf("medium pack: %#v ok=%v", med, ok)
|
||||
}
|
||||
mega, ok := CreditPackByID("mega")
|
||||
if !ok || mega.Credits != 8000 || mega.PriceUSD != 2999 {
|
||||
t.Fatalf("mega pack: %#v ok=%v", mega, ok)
|
||||
}
|
||||
if CreditPackSettingsKey("small") != "stripe.price.pack.small" {
|
||||
t.Fatalf("settings key")
|
||||
}
|
||||
if CreditPackEnvVar("xxl") != "STRIPE_PRICE_PACK_XXL" {
|
||||
t.Fatalf("env var")
|
||||
}
|
||||
if _, ok := CreditPackByID("nope"); ok {
|
||||
t.Fatal("unknown pack must miss")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFromPriceID(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{PriceIDs: map[string]string{
|
||||
"growth:monthly": "price_g_m",
|
||||
}}}
|
||||
if got := s.planFromPriceID("price_g_m"); got != "growth" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSubscriptionStatus(t *testing.T) {
|
||||
if got := NormalizeSubscriptionStatus(" Past_Due "); got != "past_due" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPastDueSubscriptionStatus(t *testing.T) {
|
||||
if !IsPastDueSubscriptionStatus("past_due") {
|
||||
t.Fatal("expected past_due")
|
||||
}
|
||||
if !IsPastDueSubscriptionStatus(" Past_Due ") {
|
||||
t.Fatal("expected normalized past_due")
|
||||
}
|
||||
if IsPastDueSubscriptionStatus("active") {
|
||||
t.Fatal("active must not be past_due")
|
||||
}
|
||||
if IsPastDueSubscriptionStatus("") {
|
||||
t.Fatal("empty must not be past_due")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStripeStatusNote(t *testing.T) {
|
||||
note := FormatStripeStatusNote("Past_Due")
|
||||
if note != "stripe_status:past_due" {
|
||||
t.Fatalf("format got %q", note)
|
||||
}
|
||||
if got := ParseStripeStatusNote(¬e); got != "past_due" {
|
||||
t.Fatalf("parse got %q", got)
|
||||
}
|
||||
ops := "ops: keep forever"
|
||||
if got := ParseStripeStatusNote(&ops); got != "" {
|
||||
t.Fatalf("ops notes must be ignored, got %q", got)
|
||||
}
|
||||
if got := ParseStripeStatusNote(nil); got != "" {
|
||||
t.Fatalf("nil got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePortalSessionMock(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
|
||||
res, err := s.CreatePortalSession(context.TODO(), uuid.New())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock || res.URL != "http://localhost:5174/billing?portal=mock" {
|
||||
t.Fatalf("got %#v", res)
|
||||
}
|
||||
// Empty secret alone (MockMode without ForceMock) also returns mock portal deep-link.
|
||||
s2 := &StripeService{Cfg: StripeConfig{WebOrigin: "http://localhost:5174"}}
|
||||
res, err = s2.CreatePortalSession(context.TODO(), uuid.New())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock {
|
||||
t.Fatalf("mock mode portal expected, got %#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCheckoutSessionMockRequiresBilling(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
|
||||
_, err := s.CreateCheckoutSession(context.TODO(), uuid.New(), "a@b.c", "Acme", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||||
if err == nil || err.Error() != "billing service not configured" {
|
||||
t.Fatalf("want billing not configured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCreditPackCheckoutMockRequiresBilling(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"}}
|
||||
_, err := s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "small")
|
||||
if err == nil || err.Error() != "billing service not configured" {
|
||||
t.Fatalf("want billing not configured, got %v", err)
|
||||
}
|
||||
_, err = s.CreateCreditPackCheckout(context.TODO(), uuid.New(), "a@b.c", "Acme", "nope")
|
||||
if !errors.Is(err, ErrStripePlanUnsupported) {
|
||||
t.Fatalf("unknown pack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditsFromPackMetadata(t *testing.T) {
|
||||
got, err := creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "999999"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 65 {
|
||||
t.Fatalf("catalog must win over inflated credits, got %d", got)
|
||||
}
|
||||
got, err = creditsFromPackMetadata(map[string]string{"pack": "small", "credits": "not-a-number"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 65 {
|
||||
t.Fatalf("catalog must win over garbage credits, got %d", got)
|
||||
}
|
||||
_, err = creditsFromPackMetadata(map[string]string{"pack": "unknown", "credits": "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("unknown pack with garbage credits must fail")
|
||||
}
|
||||
got, err = creditsFromPackMetadata(map[string]string{"pack": "custom", "credits": "42"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 42 {
|
||||
t.Fatalf("unknown pack may use positive credits metadata, got %d", got)
|
||||
}
|
||||
_, err = creditsFromPackMetadata(map[string]string{"pack": "nope"})
|
||||
if !errors.Is(err, ErrStripePlanUnsupported) {
|
||||
t.Fatalf("empty credits unknown pack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWebhookForceMockUnsignedNeedsStore(t *testing.T) {
|
||||
s := &StripeService{Cfg: StripeConfig{ForceMock: true}}
|
||||
err := s.HandleWebhook(context.TODO(), []byte(`{"id":"evt_local","type":"ping"}`), "")
|
||||
if err == nil || err.Error() != "stripe store not configured" {
|
||||
t.Fatalf("want store not configured, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func signStripePayload(t *testing.T, secret string, payload []byte) string {
|
||||
t.Helper()
|
||||
ts := time.Now().Unix()
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = fmt.Fprintf(mac, "%d.", ts)
|
||||
_, _ = mac.Write(payload)
|
||||
return fmt.Sprintf("t=%d,v1=%s", ts, hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package billing
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseUsageRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "30d"},
|
||||
{"7d", "7d"},
|
||||
{"30D", "30d"},
|
||||
{" cycle ", "cycle"},
|
||||
{"all", "all"},
|
||||
{"week", "30d"},
|
||||
{"90d", "30d"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ParseUsageRange(c.in); got != c.want {
|
||||
t.Fatalf("ParseUsageRange(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/woocommerce"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AudienceFilter is the structured form of email_campaigns.audience_filter.
|
||||
// UI shape uses type + category_ids; API/docs also accept bought_category directly.
|
||||
type AudienceFilter struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
CategoryIDs []string `json:"category_ids,omitempty"`
|
||||
BoughtCategory string `json:"bought_category,omitempty"`
|
||||
NotBoughtCategory string `json:"not_bought_category,omitempty"`
|
||||
BoughtCategories []string `json:"bought_categories,omitempty"`
|
||||
Emails []string `json:"emails,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveAudience returns campaign recipients from explicit emails and/or Woo order history.
|
||||
// Bought/not-bought category matching is best-effort over synced woo_orders / order_items.
|
||||
func (s *Service) ResolveAudience(ctx context.Context, companyID uuid.UUID, filter AudienceFilter, limit int) (woocommerce.AudienceResult, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
if limit > 5000 {
|
||||
limit = 5000
|
||||
}
|
||||
|
||||
boughtList, notBought, err := s.resolveBoughtCategories(ctx, companyID, filter)
|
||||
if err != nil {
|
||||
return woocommerce.AudienceResult{}, err
|
||||
}
|
||||
|
||||
if len(boughtList) > 0 {
|
||||
woo := &woocommerce.Service{Pool: s.Pool}
|
||||
if len(boughtList) == 1 && boughtList[0] == "__any_order__" {
|
||||
res, err := woo.AudienceAnyOrdersExcept(ctx, companyID, notBought, limit)
|
||||
if err != nil {
|
||||
return woocommerce.AudienceResult{}, err
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, c := range res.Customers {
|
||||
seen[strings.ToLower(c.Email)] = struct{}{}
|
||||
}
|
||||
for _, raw := range filter.Emails {
|
||||
if len(res.Customers) >= limit {
|
||||
break
|
||||
}
|
||||
email, err := NormalizeEmail(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
res.Customers = append(res.Customers, woocommerce.AudienceCustomer{Email: email})
|
||||
seen[email] = struct{}{}
|
||||
}
|
||||
res.Total = len(res.Customers)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
merged := woocommerce.AudienceResult{
|
||||
Customers: make([]woocommerce.AudienceCustomer, 0),
|
||||
Note: "best-effort from synced Woo orders (campaign audience_filter)",
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, bought := range boughtList {
|
||||
if len(merged.Customers) >= limit {
|
||||
break
|
||||
}
|
||||
res, err := woo.AudienceBoughtCategories(ctx, companyID, bought, notBought, limit)
|
||||
if err != nil {
|
||||
return woocommerce.AudienceResult{}, err
|
||||
}
|
||||
if res.Note != "" {
|
||||
merged.Note = res.Note
|
||||
}
|
||||
for _, c := range res.Customers {
|
||||
email := strings.ToLower(strings.TrimSpace(c.Email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
seen[email] = struct{}{}
|
||||
merged.Customers = append(merged.Customers, c)
|
||||
if len(merged.Customers) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, raw := range filter.Emails {
|
||||
if len(merged.Customers) >= limit {
|
||||
break
|
||||
}
|
||||
email, err := NormalizeEmail(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
merged.Customers = append(merged.Customers, woocommerce.AudienceCustomer{Email: email})
|
||||
seen[email] = struct{}{}
|
||||
}
|
||||
merged.Total = len(merged.Customers)
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
out := woocommerce.AudienceResult{
|
||||
Customers: make([]woocommerce.AudienceCustomer, 0),
|
||||
Note: "explicit email list (no bought_category filter)",
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range filter.Emails {
|
||||
email, err := NormalizeEmail(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[email]; ok {
|
||||
continue
|
||||
}
|
||||
out.Customers = append(out.Customers, woocommerce.AudienceCustomer{Email: email})
|
||||
seen[email] = struct{}{}
|
||||
if len(out.Customers) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
out.Total = len(out.Customers)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveBoughtCategories(ctx context.Context, companyID uuid.UUID, filter AudienceFilter) ([]string, string, error) {
|
||||
notBought := strings.TrimSpace(filter.NotBoughtCategory)
|
||||
bought := make([]string, 0)
|
||||
add := func(v string) {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
for _, existing := range bought {
|
||||
if strings.EqualFold(existing, v) {
|
||||
return
|
||||
}
|
||||
}
|
||||
bought = append(bought, v)
|
||||
}
|
||||
add(filter.BoughtCategory)
|
||||
for _, v := range filter.BoughtCategories {
|
||||
add(v)
|
||||
}
|
||||
|
||||
typ := strings.ToLower(strings.TrimSpace(filter.Type))
|
||||
names, err := s.categoryNamesByIDs(ctx, companyID, filter.CategoryIDs)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "purchased", "by_category":
|
||||
for _, name := range names {
|
||||
add(name)
|
||||
}
|
||||
case "not_purchased":
|
||||
if notBought == "" && len(names) > 0 {
|
||||
notBought = names[0]
|
||||
}
|
||||
if len(bought) == 0 {
|
||||
return []string{"__any_order__"}, notBought, nil
|
||||
}
|
||||
default:
|
||||
// Keep explicit bought_category / bought_categories when type is empty/all.
|
||||
if len(bought) == 0 {
|
||||
for _, name := range names {
|
||||
add(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bought, notBought, nil
|
||||
}
|
||||
|
||||
func (s *Service) categoryNamesByIDs(ctx context.Context, companyID uuid.UUID, rawIDs []string) ([]string, error) {
|
||||
ids := make([]uuid.UUID, 0, len(rawIDs))
|
||||
for _, raw := range rawIDs {
|
||||
id, err := uuid.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT name FROM categories
|
||||
WHERE company_id = $1 AND id = ANY($2::uuid[]) AND is_active = true`, companyID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]string, 0, len(ids))
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveAudienceMap accepts the loose map[string]any shape used by campaign Create/Update inputs.
|
||||
func (s *Service) ResolveAudienceMap(ctx context.Context, companyID uuid.UUID, raw map[string]any, limit int) (woocommerce.AudienceResult, error) {
|
||||
return s.ResolveAudience(ctx, companyID, AudienceFilterFromMap(raw), limit)
|
||||
}
|
||||
|
||||
// AudienceFilterFromMap converts a JSON-object audience_filter into AudienceFilter.
|
||||
func AudienceFilterFromMap(raw map[string]any) AudienceFilter {
|
||||
if raw == nil {
|
||||
return AudienceFilter{}
|
||||
}
|
||||
b, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return AudienceFilter{}
|
||||
}
|
||||
return ParseAudienceFilter(b)
|
||||
}
|
||||
|
||||
// ParseAudienceFilter decodes audience_filter JSONB.
|
||||
func ParseAudienceFilter(raw []byte) AudienceFilter {
|
||||
var f AudienceFilter
|
||||
if len(raw) == 0 {
|
||||
return f
|
||||
}
|
||||
_ = json.Unmarshal(raw, &f)
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package campaigns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseAudienceFilterUIShape(t *testing.T) {
|
||||
raw := []byte(`{"type":"purchased","category_ids":["11111111-1111-1111-1111-111111111111"],"bought_category":"Demo Electronics"}`)
|
||||
f := ParseAudienceFilter(raw)
|
||||
if f.Type != "purchased" {
|
||||
t.Fatalf("type=%q", f.Type)
|
||||
}
|
||||
if f.BoughtCategory != "Demo Electronics" {
|
||||
t.Fatalf("bought=%q", f.BoughtCategory)
|
||||
}
|
||||
if len(f.CategoryIDs) != 1 {
|
||||
t.Fatalf("category_ids=%v", f.CategoryIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudienceFilterFromMap(t *testing.T) {
|
||||
f := AudienceFilterFromMap(map[string]any{
|
||||
"type": "not_purchased",
|
||||
"category_ids": []any{"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"},
|
||||
"bought_category": "",
|
||||
})
|
||||
if f.Type != "not_purchased" {
|
||||
t.Fatalf("type=%q", f.Type)
|
||||
}
|
||||
if len(f.CategoryIDs) != 1 {
|
||||
t.Fatalf("ids=%v", f.CategoryIDs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestResolveAudienceSeededWooDemo(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
var companyID uuid.UUID
|
||||
var af []byte
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT company_id, audience_filter
|
||||
FROM email_campaigns
|
||||
WHERE name LIKE 'Woo demo%'
|
||||
ORDER BY updated_at DESC LIMIT 1`).Scan(&companyID, &af)
|
||||
if err != nil {
|
||||
t.Skip("no seeded woo demo campaign:", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(af, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &Service{Pool: pg}
|
||||
res, err := svc.ResolveAudienceMap(ctx, companyID, m, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Total < 3 {
|
||||
t.Fatalf("expected >=3 audience customers, got %d (%v)", res.Total, res.Customers)
|
||||
}
|
||||
t.Logf("resolved %d customers via campaign filter: %+v", res.Total, res.Customers)
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
maxCampaignProductIDs = 100
|
||||
maxCampaignCategoryIDs = 50
|
||||
)
|
||||
|
||||
// Campaign is the API representation of email_campaigns (+ latest version fields).
|
||||
type Campaign struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
Season string `json:"season,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||||
ProductIDs []uuid.UUID `json:"product_ids"`
|
||||
Prompt string `json:"prompt"`
|
||||
UseDefaultPrompt bool `json:"use_default_prompt"`
|
||||
AudienceFilter map[string]any `json:"audience_filter"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
|
||||
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
HTMLBody string `json:"html_body,omitempty"`
|
||||
PlainBody string `json:"plain_body,omitempty"`
|
||||
LatestVersion *Version `json:"latest_version,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Version struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Version int `json:"version"`
|
||||
Subject string `json:"subject"`
|
||||
HTMLBody string `json:"html_body"`
|
||||
PlainBody string `json:"plain_body"`
|
||||
GenerationMode string `json:"generation_mode"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Name string `json:"name"`
|
||||
TemplateKey string `json:"template_key"`
|
||||
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||||
ProductIDs []uuid.UUID `json:"product_ids"`
|
||||
Prompt string `json:"prompt"`
|
||||
UseDefaultPrompt *bool `json:"use_default_prompt"`
|
||||
AudienceFilter map[string]any `json:"audience_filter"`
|
||||
}
|
||||
|
||||
type UpdateInput struct {
|
||||
Name *string `json:"name"`
|
||||
TemplateKey *string `json:"template_key"`
|
||||
Status *string `json:"status"`
|
||||
CategoryIDs []uuid.UUID `json:"category_ids"`
|
||||
ProductIDs []uuid.UUID `json:"product_ids"`
|
||||
Prompt *string `json:"prompt"`
|
||||
UseDefaultPrompt *bool `json:"use_default_prompt"`
|
||||
AudienceFilter map[string]any `json:"audience_filter"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
}
|
||||
|
||||
type GenerateInput struct {
|
||||
Mode string `json:"mode"` // template | ai
|
||||
Force bool `json:"force"`
|
||||
UseAI *bool `json:"use_ai"`
|
||||
}
|
||||
|
||||
type SendTestInput struct {
|
||||
To string `json:"to"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type ScheduleInput struct {
|
||||
ScheduledAt time.Time `json:"scheduled_at"`
|
||||
}
|
||||
|
||||
type SendInput struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
Recipients []string `json:"recipients"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, companyID uuid.UUID, limit, offset int) ([]Campaign, int64, error) {
|
||||
var total int64
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM email_campaigns WHERE company_id = $1`, companyID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
|
||||
audience_filter, scheduled_at, sent_at, created_at, updated_at
|
||||
FROM email_campaigns WHERE company_id = $1
|
||||
ORDER BY updated_at DESC LIMIT $2 OFFSET $3`, companyID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Campaign, 0)
|
||||
for rows.Next() {
|
||||
c, err := scanCampaign(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
// One round-trip for the page (was N+1 via attachLatestVersion per row).
|
||||
_ = s.attachLatestVersions(ctx, out)
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, companyID, id uuid.UUID) (Campaign, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, template_key, status, category_ids, product_ids, prompt, use_default_prompt,
|
||||
audience_filter, scheduled_at, sent_at, created_at, updated_at
|
||||
FROM email_campaigns WHERE company_id = $1 AND id = $2`, companyID, id)
|
||||
c, err := scanCampaign(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Campaign{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
_ = s.attachLatestVersion(ctx, &c)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, companyID uuid.UUID, createdBy *uuid.UUID, in CreateInput) (Campaign, error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return Campaign{}, ErrNameRequired
|
||||
}
|
||||
tpl, err := GetTemplate(in.TemplateKey)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
useDefault := true
|
||||
if in.UseDefaultPrompt != nil {
|
||||
useDefault = *in.UseDefaultPrompt
|
||||
}
|
||||
prompt := SanitizePrompt(in.Prompt)
|
||||
if useDefault && prompt == "" {
|
||||
prompt = tpl.DefaultPrompt
|
||||
}
|
||||
if err := ValidatePrompt(prompt); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
af := in.AudienceFilter
|
||||
if af == nil {
|
||||
af = map[string]any{}
|
||||
}
|
||||
afBytes, err := json.Marshal(af)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
cats := in.CategoryIDs
|
||||
if cats == nil {
|
||||
cats = []uuid.UUID{}
|
||||
}
|
||||
prods := in.ProductIDs
|
||||
if prods == nil {
|
||||
prods = []uuid.UUID{}
|
||||
}
|
||||
if err := validateCampaignRefs(cats, prods); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO email_campaigns (
|
||||
company_id, name, template_key, category_ids, product_ids, prompt, use_default_prompt, audience_filter, created_by
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9)
|
||||
RETURNING id`,
|
||||
companyID, name, tpl.Key, cats, prods, prompt, useDefault, string(afBytes), createdBy,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, companyID, id uuid.UUID, in UpdateInput) (Campaign, error) {
|
||||
cur, err := s.Get(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
name := cur.Name
|
||||
if in.Name != nil {
|
||||
name = strings.TrimSpace(*in.Name)
|
||||
if name == "" {
|
||||
return Campaign{}, ErrNameRequired
|
||||
}
|
||||
}
|
||||
tplKey := cur.TemplateKey
|
||||
if in.TemplateKey != nil {
|
||||
tpl, err := GetTemplate(*in.TemplateKey)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
tplKey = tpl.Key
|
||||
}
|
||||
status := cur.Status
|
||||
if in.Status != nil {
|
||||
st := strings.TrimSpace(strings.ToLower(*in.Status))
|
||||
switch st {
|
||||
case "draft", "ready", "scheduled", "sent", "cancelled":
|
||||
status = st
|
||||
default:
|
||||
return Campaign{}, ErrInvalidStatus
|
||||
}
|
||||
}
|
||||
prompt := cur.Prompt
|
||||
if in.Prompt != nil {
|
||||
prompt = SanitizePrompt(*in.Prompt)
|
||||
if err := ValidatePrompt(prompt); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
}
|
||||
useDefault := cur.UseDefaultPrompt
|
||||
if in.UseDefaultPrompt != nil {
|
||||
useDefault = *in.UseDefaultPrompt
|
||||
}
|
||||
cats := cur.CategoryIDs
|
||||
if in.CategoryIDs != nil {
|
||||
cats = in.CategoryIDs
|
||||
}
|
||||
prods := cur.ProductIDs
|
||||
if in.ProductIDs != nil {
|
||||
prods = in.ProductIDs
|
||||
}
|
||||
if err := validateCampaignRefs(cats, prods); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
af := cur.AudienceFilter
|
||||
if in.AudienceFilter != nil {
|
||||
af = in.AudienceFilter
|
||||
}
|
||||
afBytes, err := json.Marshal(af)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
scheduledAt := cur.ScheduledAt
|
||||
if in.ScheduledAt != nil {
|
||||
scheduledAt = in.ScheduledAt
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE email_campaigns SET
|
||||
name=$3, template_key=$4, status=$5, category_ids=$6, product_ids=$7,
|
||||
prompt=$8, use_default_prompt=$9, audience_filter=$10::jsonb, scheduled_at=$11, updated_at=now()
|
||||
WHERE company_id=$1 AND id=$2`,
|
||||
companyID, id, name, tplKey, status, cats, prods, prompt, useDefault, string(afBytes), scheduledAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, companyID, id uuid.UUID) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM email_campaigns WHERE company_id=$1 AND id=$2`, companyID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type scannable interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanCampaign(row scannable) (Campaign, error) {
|
||||
var c Campaign
|
||||
var af []byte
|
||||
err := row.Scan(
|
||||
&c.ID, &c.Name, &c.TemplateKey, &c.Status, &c.CategoryIDs, &c.ProductIDs, &c.Prompt, &c.UseDefaultPrompt,
|
||||
&af, &c.ScheduledAt, &c.SentAt, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if c.CategoryIDs == nil {
|
||||
c.CategoryIDs = []uuid.UUID{}
|
||||
}
|
||||
if c.ProductIDs == nil {
|
||||
c.ProductIDs = []uuid.UUID{}
|
||||
}
|
||||
c.AudienceFilter = map[string]any{}
|
||||
if len(af) > 0 {
|
||||
_ = json.Unmarshal(af, &c.AudienceFilter)
|
||||
}
|
||||
if tpl, err := GetTemplate(c.TemplateKey); err == nil {
|
||||
c.Season = tpl.Season
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func applyVersionFields(c *Campaign, v Version) {
|
||||
c.LatestVersion = &v
|
||||
c.Subject = v.Subject
|
||||
c.HTMLBody = v.HTMLBody
|
||||
c.PlainBody = v.PlainBody
|
||||
}
|
||||
|
||||
func (s *Service) attachLatestVersion(ctx context.Context, c *Campaign) error {
|
||||
var v Version
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, version, subject, html_body, plain_body, generation_mode, generated_at
|
||||
FROM email_campaign_versions
|
||||
WHERE campaign_id=$1
|
||||
ORDER BY version DESC LIMIT 1`, c.ID).Scan(
|
||||
&v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("campaigns: attach version: %v", err)
|
||||
return err
|
||||
}
|
||||
applyVersionFields(c, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// latestVersionsByCampaignIDsSQL loads one latest version per campaign (batch for List).
|
||||
const latestVersionsByCampaignIDsSQL = `
|
||||
SELECT DISTINCT ON (campaign_id)
|
||||
campaign_id, id, version, subject, html_body, plain_body, generation_mode, generated_at
|
||||
FROM email_campaign_versions
|
||||
WHERE campaign_id = ANY($1)
|
||||
ORDER BY campaign_id, version DESC`
|
||||
|
||||
// attachLatestVersions fills LatestVersion/subject/body fields for a page of campaigns in one query.
|
||||
func (s *Service) attachLatestVersions(ctx context.Context, campaigns []Campaign) error {
|
||||
if len(campaigns) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]uuid.UUID, len(campaigns))
|
||||
for i := range campaigns {
|
||||
ids[i] = campaigns[i].ID
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, latestVersionsByCampaignIDsSQL, ids)
|
||||
if err != nil {
|
||||
log.Printf("campaigns: attach versions batch: %v", err)
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
byID := make(map[uuid.UUID]Version, len(campaigns))
|
||||
for rows.Next() {
|
||||
var campaignID uuid.UUID
|
||||
var v Version
|
||||
if err := rows.Scan(
|
||||
&campaignID, &v.ID, &v.Version, &v.Subject, &v.HTMLBody, &v.PlainBody, &v.GenerationMode, &v.GeneratedAt,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
byID[campaignID] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range campaigns {
|
||||
if v, ok := byID[campaigns[i].ID]; ok {
|
||||
applyVersionFields(&campaigns[i], v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCampaignRefs(cats, prods []uuid.UUID) error {
|
||||
if len(cats) > maxCampaignCategoryIDs {
|
||||
return ErrTooManyCategoryIDs
|
||||
}
|
||||
if len(prods) > maxCampaignProductIDs {
|
||||
return ErrTooManyProductIDs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package campaigns
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("campaign not found")
|
||||
ErrProviderNotFound = errors.New("email provider not configured")
|
||||
ErrProviderUnverified = errors.New("email provider not verified")
|
||||
ErrInvalidEmail = errors.New("invalid email address")
|
||||
ErrInvalidTemplate = errors.New("invalid template_key")
|
||||
ErrInvalidStatus = errors.New("invalid status")
|
||||
ErrMissingContent = errors.New("campaign has no generated content")
|
||||
ErrAIRequiresUpgrade = errors.New("campaign AI generate requires a paid plan or AI credits")
|
||||
ErrInsufficientCredits = errors.New("insufficient credits for campaign AI generate")
|
||||
ErrAIUnavailable = errors.New("AI generation is not configured")
|
||||
ErrRateLimited = errors.New("rate limit exceeded")
|
||||
ErrUnsubscribed = errors.New("recipient is unsubscribed")
|
||||
ErrMissingUnsubscribe = errors.New("generated HTML missing unsubscribe footer")
|
||||
ErrPromptTooLong = errors.New("prompt exceeds maximum length")
|
||||
ErrNameRequired = errors.New("name required")
|
||||
ErrNoRecipients = errors.New("no recipients")
|
||||
ErrConfirmRequired = errors.New("confirmation required to send campaign")
|
||||
ErrTooManyProductIDs = errors.New("too many product_ids")
|
||||
ErrTooManyCategoryIDs = errors.New("too many category_ids")
|
||||
)
|
||||
|
||||
// ClientError reports whether err is a known client-facing campaign validation error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
switch {
|
||||
case err == nil:
|
||||
return "", false
|
||||
case errors.Is(err, ErrInvalidTemplate),
|
||||
errors.Is(err, ErrInvalidStatus),
|
||||
errors.Is(err, ErrInvalidEmail),
|
||||
errors.Is(err, ErrMissingContent),
|
||||
errors.Is(err, ErrMissingUnsubscribe),
|
||||
errors.Is(err, ErrPromptTooLong),
|
||||
errors.Is(err, ErrNameRequired),
|
||||
errors.Is(err, ErrNoRecipients),
|
||||
errors.Is(err, ErrAIUnavailable),
|
||||
errors.Is(err, ErrConfirmRequired),
|
||||
errors.Is(err, ErrTooManyProductIDs),
|
||||
errors.Is(err, ErrTooManyCategoryIDs),
|
||||
errors.Is(err, ErrUnsubscribed),
|
||||
errors.Is(err, ErrRateLimited),
|
||||
errors.Is(err, ErrProviderNotFound),
|
||||
errors.Is(err, ErrProviderUnverified):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (s *Service) Generate(ctx context.Context, companyID, id uuid.UUID, in GenerateInput) (Campaign, error) {
|
||||
if !s.allowGenerate(companyID.String()) {
|
||||
return Campaign{}, ErrRateLimited
|
||||
}
|
||||
c, err := s.Get(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
mode := strings.ToLower(strings.TrimSpace(in.Mode))
|
||||
if mode == "" {
|
||||
if in.UseAI != nil && *in.UseAI {
|
||||
mode = "ai"
|
||||
} else {
|
||||
mode = "template"
|
||||
}
|
||||
}
|
||||
if mode != "template" && mode != "ai" {
|
||||
return Campaign{}, fmt.Errorf("mode must be template or ai")
|
||||
}
|
||||
|
||||
tpl, err := GetTemplate(c.TemplateKey)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
brandName := s.companyName(ctx, companyID)
|
||||
brand, _ := company.LoadBrand(ctx, s.Pool, companyID)
|
||||
subject := renderSubject(tpl, brandName)
|
||||
products := s.loadProductSnippets(ctx, companyID, c.ProductIDs, c.CategoryIDs)
|
||||
logoAbs := company.AbsoluteLogoForEmbed(s.PublicAPIURL, s.TokenSigningSecret, companyID, brand.LogoURL)
|
||||
html := templateHTML(subject, defaultIntro(tpl, brandName), buildProductHTML(products), s.WebOrigin, logoAbs)
|
||||
plain := subject + "\n\n" + defaultIntro(tpl, brandName) + "\n\n" + productPlainList(products)
|
||||
|
||||
if mode == "ai" {
|
||||
if s.Billing != nil {
|
||||
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.campaign_ai", "marketing.campaigns.generate_ai"); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
ent, err := s.Billing.EntitlementsForCompany(ctx, companyID)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
// Free tier: CanUseAI is false when no credits / free plan.
|
||||
// Paid with CanUseAI but empty wallet must not run AI (no silent free generate).
|
||||
if !ent.CanUseAI || ent.IsFreePlan {
|
||||
return Campaign{}, ErrAIRequiresUpgrade
|
||||
}
|
||||
if ent.RemainingCredits < 1 {
|
||||
return Campaign{}, ErrInsufficientCredits
|
||||
}
|
||||
}
|
||||
var completer processing.Completer
|
||||
if s.AI != nil {
|
||||
cplt, _, _, rerr := s.AI.ResolveCompleter(ctx, companyID)
|
||||
if rerr != nil {
|
||||
return Campaign{}, ErrAIUnavailable
|
||||
}
|
||||
completer = cplt
|
||||
} else {
|
||||
completer = s.Completer
|
||||
}
|
||||
if completer == nil {
|
||||
return Campaign{}, ErrAIUnavailable
|
||||
}
|
||||
if en, ok := completer.(processing.EnableChecker); ok && !en.Enabled() {
|
||||
return Campaign{}, ErrAIUnavailable
|
||||
}
|
||||
sysTpl := ""
|
||||
userTpl := ""
|
||||
lang := company.LoadLanguage(ctx, s.Pool, companyID)
|
||||
if s.Prompts != nil {
|
||||
if resolved, perr := s.Prompts.Resolve(ctx, companyID, aiprompts.KeyCampaignEmail, lang); perr == nil {
|
||||
sysTpl = resolved.SystemTemplate
|
||||
userTpl = resolved.UserTemplate
|
||||
}
|
||||
}
|
||||
if def, ok := aiprompts.DefaultFor(aiprompts.KeyCampaignEmail); ok {
|
||||
if strings.TrimSpace(sysTpl) == "" {
|
||||
sysTpl = def.SystemTemplate
|
||||
}
|
||||
if strings.TrimSpace(userTpl) == "" {
|
||||
userTpl = def.UserTemplate
|
||||
}
|
||||
}
|
||||
userPrompt := c.Prompt
|
||||
if c.UseDefaultPrompt || strings.TrimSpace(userPrompt) == "" {
|
||||
userPrompt = tpl.DefaultPrompt
|
||||
}
|
||||
userPrompt = SanitizePrompt(userPrompt)
|
||||
userPrompt = security.TruncateRunes(userPrompt, 600)
|
||||
products = limitProductSnippets(products, processing.MaxCampaignProducts)
|
||||
vars := aiprompts.Vars{
|
||||
"campaign_prompt": userPrompt,
|
||||
"products": productPlainList(products),
|
||||
"brand": brandName,
|
||||
"brand_voice": processing.CompactBrandPrompt(brand.PromptBlock()),
|
||||
"language": company.LanguageLabel(company.LoadLanguage(ctx, s.Pool, companyID)),
|
||||
"template_key": c.TemplateKey,
|
||||
}
|
||||
system := strings.TrimSpace(aiprompts.Render(sysTpl, vars))
|
||||
user := strings.TrimSpace(aiprompts.Render(userTpl, vars))
|
||||
if user == "" {
|
||||
user = userPrompt + "\n\nProducts:\n" + productPlainList(products) + "\nBrand: " + brandName
|
||||
}
|
||||
comp, obj, err := processing.CompleteJSON(ctx, completer, system, user, processing.CompleteOptions{
|
||||
MaxTokens: processing.MaxTokensCampaign,
|
||||
Temperature: processing.DefaultStructuredTemp,
|
||||
})
|
||||
if err != nil && obj == nil && comp.Text == "" {
|
||||
log.Printf("campaigns: ai generate failed company=%s", companyID)
|
||||
return Campaign{}, fmt.Errorf("ai generation failed")
|
||||
}
|
||||
parsed := parseAIContent(comp.Text, subject, html, plain)
|
||||
if obj != nil {
|
||||
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
parsed.Subject = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
parsed.HTML = v
|
||||
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
parsed.HTML = v
|
||||
}
|
||||
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
parsed.Plain = v
|
||||
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
parsed.Plain = v
|
||||
}
|
||||
}
|
||||
subject, html, plain = parsed.Subject, parsed.HTML, parsed.Plain
|
||||
if s.Billing != nil {
|
||||
// Always debit base feature cost (even if provider reported 0 tokens).
|
||||
if err := s.Billing.ConsumeCredits(ctx, companyID, comp.TotalTokens, "campaign_copy"); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsubURL := s.unsubscribePlaceholderURL(companyID)
|
||||
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
|
||||
html = SanitizeHTMLBody(html)
|
||||
if !HasUnsubscribeFooter(html) {
|
||||
html, plain = EnsureUnsubscribeFooter(html, plain, unsubURL)
|
||||
html = SanitizeHTMLBody(html)
|
||||
}
|
||||
if !HasUnsubscribeFooter(html) {
|
||||
return Campaign{}, ErrMissingUnsubscribe
|
||||
}
|
||||
subject = security.TruncateRunes(subject, MaxSubjectLen)
|
||||
|
||||
var nextVer int
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(version), 0) + 1 FROM email_campaign_versions
|
||||
WHERE company_id=$1 AND campaign_id=$2`, companyID, id).Scan(&nextVer)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO email_campaign_versions (
|
||||
campaign_id, company_id, version, subject, html_body, plain_body, generation_mode
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
id, companyID, nextVer, subject, html, plain, mode,
|
||||
)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE email_campaigns SET status='ready', updated_at=now() WHERE company_id=$1 AND id=$2`, companyID, id)
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) SendTest(ctx context.Context, companyID, id uuid.UUID, in SendTestInput) (Campaign, error) {
|
||||
if !s.allowSend(companyID.String() + ":test") {
|
||||
return Campaign{}, ErrRateLimited
|
||||
}
|
||||
to := strings.TrimSpace(in.To)
|
||||
if to == "" {
|
||||
to = strings.TrimSpace(in.Email)
|
||||
}
|
||||
addr, err := NormalizeEmail(to)
|
||||
if err != nil {
|
||||
return Campaign{}, ErrInvalidEmail
|
||||
}
|
||||
c, err := s.Get(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if c.LatestVersion == nil || (c.Subject == "" && c.HTMLBody == "") {
|
||||
return Campaign{}, ErrMissingContent
|
||||
}
|
||||
if s.Email == nil {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
cfg, err := s.Email.GetConfig(ctx, companyID)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if !cfg.Configured {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
if !cfg.Verified {
|
||||
return Campaign{}, ErrProviderUnverified
|
||||
}
|
||||
cid := id.String()
|
||||
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
|
||||
To: []string{addr},
|
||||
Subject: "[TEST] " + c.Subject,
|
||||
Text: c.PlainBody,
|
||||
HTML: c.HTMLBody,
|
||||
CampaignID: &cid,
|
||||
Mode: "test",
|
||||
})
|
||||
if err != nil {
|
||||
return Campaign{}, mapEmailErr(err)
|
||||
}
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Schedule(ctx context.Context, companyID, id uuid.UUID, in ScheduleInput) (Campaign, error) {
|
||||
if in.ScheduledAt.IsZero() || in.ScheduledAt.Before(time.Now().UTC().Add(-time.Minute)) {
|
||||
return Campaign{}, fmt.Errorf("scheduled_at must be in the future")
|
||||
}
|
||||
if s.Email == nil {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
cfg, err := s.Email.GetConfig(ctx, companyID)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if !cfg.Configured {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
if !cfg.Verified || !cfg.CanSendReal {
|
||||
return Campaign{}, ErrProviderUnverified
|
||||
}
|
||||
c, err := s.Get(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if c.LatestVersion == nil {
|
||||
return Campaign{}, ErrMissingContent
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
UPDATE email_campaigns SET status='scheduled', scheduled_at=$3, updated_at=now()
|
||||
WHERE company_id=$1 AND id=$2`, companyID, id, in.ScheduledAt.UTC())
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func (s *Service) Send(ctx context.Context, companyID, id uuid.UUID, in SendInput) (Campaign, error) {
|
||||
if !s.allowSend(companyID.String() + ":send") {
|
||||
return Campaign{}, ErrRateLimited
|
||||
}
|
||||
if !in.Confirm {
|
||||
return Campaign{}, ErrConfirmRequired
|
||||
}
|
||||
if !in.DryRun && s.Billing != nil {
|
||||
if err := s.Billing.AssertFeatures(ctx, companyID, "capability.email_live_send", "marketing.campaigns.send"); err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
}
|
||||
c, err := s.Get(ctx, companyID, id)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if c.LatestVersion == nil || c.HTMLBody == "" {
|
||||
return Campaign{}, ErrMissingContent
|
||||
}
|
||||
if s.Email == nil {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
cfg, err := s.Email.GetConfig(ctx, companyID)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
if !cfg.Configured {
|
||||
return Campaign{}, ErrProviderNotFound
|
||||
}
|
||||
if !in.DryRun && (!cfg.Verified || !cfg.CanSendReal) {
|
||||
return Campaign{}, ErrProviderUnverified
|
||||
}
|
||||
|
||||
recipients := in.Recipients
|
||||
if len(recipients) == 0 {
|
||||
res, err := s.ResolveAudienceMap(ctx, companyID, c.AudienceFilter, 100)
|
||||
if err != nil {
|
||||
return Campaign{}, err
|
||||
}
|
||||
for _, cust := range res.Customers {
|
||||
recipients = append(recipients, cust.Email)
|
||||
}
|
||||
}
|
||||
cleaned := make([]string, 0, len(recipients))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range recipients {
|
||||
addr, err := NormalizeEmail(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[addr]; ok {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
cleaned = append(cleaned, addr)
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return Campaign{}, ErrNoRecipients
|
||||
}
|
||||
|
||||
cid := id.String()
|
||||
_, err = s.Email.Send(ctx, companyID, email.SendRequest{
|
||||
To: cleaned,
|
||||
Subject: c.Subject,
|
||||
Text: c.PlainBody,
|
||||
HTML: c.HTMLBody,
|
||||
CampaignID: &cid,
|
||||
Mode: "blast",
|
||||
ConfirmUnderstood: email.ConfirmUnderstoodPhrase,
|
||||
ForceDryRun: in.DryRun,
|
||||
})
|
||||
if err != nil {
|
||||
return Campaign{}, mapEmailErr(err)
|
||||
}
|
||||
if !in.DryRun {
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE email_campaigns SET status='sent', sent_at=now(), updated_at=now()
|
||||
WHERE company_id=$1 AND id=$2`, companyID, id)
|
||||
}
|
||||
return s.Get(ctx, companyID, id)
|
||||
}
|
||||
|
||||
func mapEmailErr(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, email.ErrNotConfigured):
|
||||
return ErrProviderNotFound
|
||||
case errors.Is(err, email.ErrNotVerified), errors.Is(err, email.ErrNotEnabled):
|
||||
return ErrProviderUnverified
|
||||
case errors.Is(err, email.ErrRateLimited):
|
||||
return ErrRateLimited
|
||||
case errors.Is(err, email.ErrMissingConfirm):
|
||||
return ErrConfirmRequired
|
||||
case errors.Is(err, email.ErrInvalidRecipient), errors.Is(err, email.ErrInvalidFrom):
|
||||
return ErrInvalidEmail
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) companyName(ctx context.Context, companyID uuid.UUID) string {
|
||||
var name string
|
||||
_ = s.Pool.QueryRow(ctx, `SELECT COALESCE(name, '') FROM companies WHERE id=$1`, companyID).Scan(&name)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "our store"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
type productSnippet struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (s *Service) loadProductSnippets(ctx context.Context, companyID uuid.UUID, productIDs, categoryIDs []uuid.UUID) []productSnippet {
|
||||
out := make([]productSnippet, 0, 8)
|
||||
if len(productIDs) > 0 {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
|
||||
FROM processed_products
|
||||
WHERE company_id=$1 AND id = ANY($2)
|
||||
LIMIT 12`, companyID, productIDs)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if rows.Scan(&name) == nil {
|
||||
out = append(out, productSnippet{Name: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 && len(categoryIDs) > 0 {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), 'Product')
|
||||
FROM processed_products p
|
||||
JOIN categories c ON c.company_id = p.company_id
|
||||
AND (c.name = p.category OR c.unique_id = p.category OR c.id::text = p.category)
|
||||
WHERE p.company_id=$1 AND c.id = ANY($2::uuid[])
|
||||
ORDER BY p.updated_at DESC NULLS LAST
|
||||
LIMIT 12`, companyID, categoryIDs)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if rows.Scan(&name) == nil {
|
||||
out = append(out, productSnippet{Name: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT COALESCE(NULLIF(processed_name, ''), NULLIF(name, ''), 'Product')
|
||||
FROM processed_products
|
||||
WHERE company_id=$1
|
||||
ORDER BY updated_at DESC NULLS LAST
|
||||
LIMIT 6`, companyID)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if rows.Scan(&name) == nil {
|
||||
out = append(out, productSnippet{Name: name})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) unsubscribePlaceholderURL(companyID uuid.UUID) string {
|
||||
base := strings.TrimRight(s.WebOrigin, "/")
|
||||
if base == "" {
|
||||
base = strings.TrimRight(s.PublicAPIURL, "/")
|
||||
}
|
||||
if base == "" {
|
||||
return "/unsubscribe"
|
||||
}
|
||||
return base + "/unsubscribe?company=" + companyID.String()
|
||||
}
|
||||
|
||||
type aiParsed struct {
|
||||
Subject string
|
||||
HTML string
|
||||
Plain string
|
||||
}
|
||||
|
||||
func parseAIContent(text, fallbackSubject, fallbackHTML, fallbackPlain string) aiParsed {
|
||||
text = strings.TrimSpace(text)
|
||||
out := aiParsed{Subject: fallbackSubject, HTML: fallbackHTML, Plain: fallbackPlain}
|
||||
obj, err := processing.ParseJSONObject(text)
|
||||
if err == nil && obj != nil {
|
||||
if v, ok := obj["subject"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
out.Subject = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := obj["html_body"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
out.HTML = v
|
||||
} else if v, ok := obj["html"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
out.HTML = v
|
||||
}
|
||||
if v, ok := obj["plain_body"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
out.Plain = v
|
||||
} else if v, ok := obj["text"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
out.Plain = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
if strings.Contains(text, "<") {
|
||||
out.HTML = text
|
||||
out.Plain = stripTags(text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func limitProductSnippets(products []productSnippet, max int) []productSnippet {
|
||||
if max > 0 && len(products) > max {
|
||||
products = products[:max]
|
||||
}
|
||||
out := make([]productSnippet, len(products))
|
||||
copy(out, products)
|
||||
for i := range out {
|
||||
out[i].Name = security.TruncateRunes(out[i].Name, processing.MaxCampaignNameRunes)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultIntro(tpl Template, brand string) string {
|
||||
switch TemplateKey(tpl.Key) {
|
||||
case TemplateChristmas:
|
||||
return fmt.Sprintf("Season's greetings from %s — here are a few holiday favorites we think you'll love.", brand)
|
||||
case TemplateBlackFriday:
|
||||
return fmt.Sprintf("Black Friday is here. %s picked standout products worth a look before they go.", brand)
|
||||
case TemplateSpring:
|
||||
return fmt.Sprintf("Spring refresh from %s — new energy for the season ahead.", brand)
|
||||
default:
|
||||
return fmt.Sprintf("A few highlights from %s, curated for you.", brand)
|
||||
}
|
||||
}
|
||||
|
||||
func buildProductHTML(products []productSnippet) string {
|
||||
if len(products) == 0 {
|
||||
return `<p><em>Your selected products will appear here.</em></p>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<ul style="padding-left:18px">`)
|
||||
for _, p := range products {
|
||||
b.WriteString("<li>" + escapeHTML(p.Name) + "</li>")
|
||||
}
|
||||
b.WriteString("</ul>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func productPlainList(products []productSnippet) string {
|
||||
if len(products) == 0 {
|
||||
return "(no products selected)"
|
||||
}
|
||||
names := make([]string, 0, len(products))
|
||||
for _, p := range products {
|
||||
names = append(names, "- "+p.Name)
|
||||
}
|
||||
return strings.Join(names, "\n")
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestLatestVersionsByCampaignIDsSQL_batchesDistinctOn(t *testing.T) {
|
||||
if !strings.Contains(latestVersionsByCampaignIDsSQL, "DISTINCT ON (campaign_id)") {
|
||||
t.Fatal("expected DISTINCT ON so each campaign gets one latest version")
|
||||
}
|
||||
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ANY($1)") {
|
||||
t.Fatal("expected ANY($1) batch filter over campaign IDs")
|
||||
}
|
||||
if !strings.Contains(latestVersionsByCampaignIDsSQL, "ORDER BY campaign_id, version DESC") {
|
||||
t.Fatal("expected ORDER BY campaign_id, version DESC for DISTINCT ON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyVersionFields(t *testing.T) {
|
||||
c := Campaign{ID: uuid.New()}
|
||||
v := Version{
|
||||
ID: uuid.New(),
|
||||
Version: 3,
|
||||
Subject: "Hello",
|
||||
HTMLBody: "<p>Hi</p>",
|
||||
PlainBody: "Hi",
|
||||
}
|
||||
applyVersionFields(&c, v)
|
||||
if c.Subject != "Hello" || c.HTMLBody != "<p>Hi</p>" || c.PlainBody != "Hi" {
|
||||
t.Fatalf("subject/body not applied: %+v", c)
|
||||
}
|
||||
if c.LatestVersion == nil || c.LatestVersion.Version != 3 {
|
||||
t.Fatalf("LatestVersion not applied: %+v", c.LatestVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachLatestVersionsEmpty(t *testing.T) {
|
||||
s := &Service{}
|
||||
if err := s.attachLatestVersions(context.TODO(), nil); err != nil {
|
||||
t.Fatalf("empty page should no-op: %v", err)
|
||||
}
|
||||
if err := s.attachLatestVersions(context.TODO(), []Campaign{}); err != nil {
|
||||
t.Fatalf("empty slice should no-op: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestValidateCampaignRefs(t *testing.T) {
|
||||
okCats := make([]uuid.UUID, maxCampaignCategoryIDs)
|
||||
okProds := make([]uuid.UUID, maxCampaignProductIDs)
|
||||
for i := range okCats {
|
||||
okCats[i] = uuid.New()
|
||||
}
|
||||
for i := range okProds {
|
||||
okProds[i] = uuid.New()
|
||||
}
|
||||
if err := validateCampaignRefs(okCats, okProds); err != nil {
|
||||
t.Fatalf("expected ok, got %v", err)
|
||||
}
|
||||
|
||||
tooManyCats := append(append([]uuid.UUID{}, okCats...), uuid.New())
|
||||
if err := validateCampaignRefs(tooManyCats, nil); err != ErrTooManyCategoryIDs {
|
||||
t.Fatalf("got %v want ErrTooManyCategoryIDs", err)
|
||||
}
|
||||
|
||||
tooManyProds := append(append([]uuid.UUID{}, okProds...), uuid.New())
|
||||
if err := validateCampaignRefs(nil, tooManyProds); err != ErrTooManyProductIDs {
|
||||
t.Fatalf("got %v want ErrTooManyProductIDs", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientErrorTooManyRefs(t *testing.T) {
|
||||
for _, err := range []error{ErrTooManyProductIDs, ErrTooManyCategoryIDs} {
|
||||
msg, ok := ClientError(err)
|
||||
if !ok || msg == "" {
|
||||
t.Fatalf("ClientError(%v) ok=%v msg=%q", err, ok, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/email"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Service is the email campaigns API surface (CRUD + generate + schedule/send).
|
||||
// Tenant sending goes through email.Service (verified provider, rate limits, unsub).
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
Billing *billing.Service
|
||||
Email *email.Service
|
||||
Completer processing.Completer
|
||||
AI *aiprovider.Service
|
||||
Prompts *aiprompts.Service
|
||||
WebOrigin string
|
||||
PublicAPIURL string
|
||||
// TokenSigningSecret signs public brand-logo URLs for email embeds.
|
||||
TokenSigningSecret string
|
||||
HTTP *http.Client
|
||||
|
||||
genMu sync.Mutex
|
||||
genHit map[string][]time.Time
|
||||
sendMu sync.Mutex
|
||||
sendHit map[string][]time.Time
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, billingSvc *billing.Service, emailSvc *email.Service) *Service {
|
||||
return &Service{
|
||||
Pool: pool,
|
||||
Billing: billingSvc,
|
||||
Email: emailSvc,
|
||||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||||
genHit: make(map[string][]time.Time),
|
||||
sendHit: make(map[string][]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
generateRPM = 10
|
||||
sendRPM = 20
|
||||
)
|
||||
|
||||
func (s *Service) allowGenerate(companyID string) bool {
|
||||
return allowWindow(&s.genMu, s.genHit, companyID, generateRPM, time.Minute)
|
||||
}
|
||||
|
||||
func (s *Service) allowSend(companyID string) bool {
|
||||
return allowWindow(&s.sendMu, s.sendHit, companyID, sendRPM, time.Minute)
|
||||
}
|
||||
|
||||
func allowWindow(mu *sync.Mutex, hits map[string][]time.Time, key string, limit int, window time.Duration) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
ts := hits[key]
|
||||
kept := ts[:0]
|
||||
for _, t := range ts {
|
||||
if t.After(cutoff) {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) >= limit {
|
||||
hits[key] = kept
|
||||
return false
|
||||
}
|
||||
hits[key] = append(kept, now)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TemplateKey is a seasonal or custom campaign template identifier.
|
||||
type TemplateKey string
|
||||
|
||||
const (
|
||||
TemplateChristmas TemplateKey = "christmas"
|
||||
TemplateBlackFriday TemplateKey = "black_friday"
|
||||
TemplateSpring TemplateKey = "spring"
|
||||
TemplateCustom TemplateKey = "custom"
|
||||
)
|
||||
|
||||
type Template struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Season string `json:"season"`
|
||||
DefaultSubject string `json:"default_subject"`
|
||||
DefaultPrompt string `json:"default_prompt"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var builtInTemplates = []Template{
|
||||
{
|
||||
Key: string(TemplateChristmas),
|
||||
Name: "Christmas",
|
||||
Season: "christmas",
|
||||
DefaultSubject: "Holiday picks from {{brand}}",
|
||||
DefaultPrompt: "Warm Christmas email for these products. Festive, concise, clear CTA. JSON only.",
|
||||
Description: "Festive seasonal campaign for holiday shoppers.",
|
||||
},
|
||||
{
|
||||
Key: string(TemplateBlackFriday),
|
||||
Name: "Black Friday",
|
||||
Season: "black_friday",
|
||||
DefaultSubject: "Black Friday deals from {{brand}}",
|
||||
DefaultPrompt: "Urgent Black Friday email for these products. Limited-time value, no false claims, strong CTA. JSON only.",
|
||||
Description: "Deal-focused Black Friday / Cyber Week campaign.",
|
||||
},
|
||||
{
|
||||
Key: string(TemplateSpring),
|
||||
Name: "Spring",
|
||||
Season: "spring",
|
||||
DefaultSubject: "Fresh for spring — {{brand}}",
|
||||
DefaultPrompt: "Light spring email for these products. Renewal + practical benefits, clear CTA. JSON only.",
|
||||
Description: "Seasonal spring refresh campaign.",
|
||||
},
|
||||
{
|
||||
Key: string(TemplateCustom),
|
||||
Name: "Custom",
|
||||
Season: "custom",
|
||||
DefaultSubject: "News from {{brand}}",
|
||||
DefaultPrompt: "Clear marketing email for these products. Short subject, scannable body, CTA. JSON only.",
|
||||
Description: "Blank slate with sensible defaults.",
|
||||
},
|
||||
}
|
||||
|
||||
func ListTemplates() []Template {
|
||||
out := make([]Template, len(builtInTemplates))
|
||||
copy(out, builtInTemplates)
|
||||
return out
|
||||
}
|
||||
|
||||
func GetTemplate(key string) (Template, error) {
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
if key == "" {
|
||||
key = string(TemplateCustom)
|
||||
}
|
||||
for _, t := range builtInTemplates {
|
||||
if t.Key == key {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return Template{}, ErrInvalidTemplate
|
||||
}
|
||||
|
||||
func ValidTemplateKey(key string) bool {
|
||||
_, err := GetTemplate(key)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func renderSubject(tpl Template, brand string) string {
|
||||
if brand == "" {
|
||||
brand = "our store"
|
||||
}
|
||||
return strings.ReplaceAll(tpl.DefaultSubject, "{{brand}}", brand)
|
||||
}
|
||||
|
||||
func templateHTML(subject, intro, productBlock, ctaURL, logoURL string) string {
|
||||
if ctaURL == "" {
|
||||
ctaURL = "#"
|
||||
}
|
||||
logoBlock := ""
|
||||
if strings.TrimSpace(logoURL) != "" {
|
||||
logoBlock = fmt.Sprintf(
|
||||
`<p style="margin:0 0 16px"><img src="%s" alt="" width="120" style="max-width:160px;height:auto;border:0" /></p>`,
|
||||
escapeAttr(logoURL),
|
||||
)
|
||||
}
|
||||
return fmt.Sprintf(`<!DOCTYPE html><html><body style="font-family:Arial,sans-serif;color:#222;line-height:1.5">
|
||||
%s<h1 style="font-size:22px;margin:0 0 12px">%s</h1>
|
||||
<p>%s</p>
|
||||
%s
|
||||
<p style="margin:24px 0"><a href="%s" style="background:#2d1b4e;color:#fff;padding:10px 16px;text-decoration:none;border-radius:4px">Shop now</a></p>
|
||||
</body></html>`, logoBlock, escapeHTML(subject), escapeHTML(intro), productBlock, escapeAttr(ctaURL))
|
||||
}
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
func escapeAttr(s string) string {
|
||||
return escapeHTML(s)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package campaigns
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListTemplates(t *testing.T) {
|
||||
tpls := ListTemplates()
|
||||
if len(tpls) != 4 {
|
||||
t.Fatalf("expected 4 templates, got %d", len(tpls))
|
||||
}
|
||||
for _, key := range []string{"christmas", "black_friday", "spring", "custom"} {
|
||||
if !ValidTemplateKey(key) {
|
||||
t.Fatalf("expected valid key %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
e, err := NormalizeEmail(" User@Example.COM ")
|
||||
if err != nil || e != "user@example.com" {
|
||||
t.Fatalf("got %q err=%v", e, err)
|
||||
}
|
||||
if _, err := NormalizeEmail("not-an-email"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribeFooter(t *testing.T) {
|
||||
html, plain := EnsureUnsubscribeFooter("<p>Hi</p>", "Hi", "https://example.com/unsubscribe?token=abc")
|
||||
if !HasUnsubscribeFooter(html) {
|
||||
t.Fatalf("missing footer in %s", html)
|
||||
}
|
||||
if plain == "" {
|
||||
t.Fatal("plain empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package campaigns
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxPromptLen = security.MaxCampaignPromptRunes
|
||||
MaxSubjectLen = 200
|
||||
MaxHTMLBodyLen = security.MaxEmailHTMLRunes
|
||||
unsubscribeMark = "data-descrybe-unsubscribe"
|
||||
)
|
||||
|
||||
var emailLoose = regexp.MustCompile(`(?i)^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
|
||||
|
||||
// NormalizeEmail lowercases and trims; returns ErrInvalidEmail when invalid.
|
||||
func NormalizeEmail(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||
if raw == "" || len(raw) > 254 {
|
||||
return "", ErrInvalidEmail
|
||||
}
|
||||
addr, err := mail.ParseAddress(raw)
|
||||
if err != nil {
|
||||
return "", ErrInvalidEmail
|
||||
}
|
||||
e := strings.TrimSpace(strings.ToLower(addr.Address))
|
||||
if !emailLoose.MatchString(e) {
|
||||
return "", ErrInvalidEmail
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func EmailHash(email string) string {
|
||||
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email))))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func NewToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func ValidatePrompt(prompt string) error {
|
||||
if security.CapPromptLength(prompt, MaxPromptLen) {
|
||||
return ErrPromptTooLong
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizePrompt bounds and soft-filters campaign prompts before AI / storage.
|
||||
func SanitizePrompt(prompt string) string {
|
||||
return security.SanitizePrompt(prompt, MaxPromptLen)
|
||||
}
|
||||
|
||||
// SanitizeHTMLBody strips dangerous markup from generated/stored campaign HTML.
|
||||
func SanitizeHTMLBody(html string) string {
|
||||
return security.SanitizeEmailHTML(html)
|
||||
}
|
||||
|
||||
func HasUnsubscribeFooter(html string) bool {
|
||||
lower := strings.ToLower(html)
|
||||
if strings.Contains(lower, unsubscribeMark) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(lower, "unsubscribe") && (strings.Contains(lower, "href=") || strings.Contains(lower, "/unsubscribe")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func EnsureUnsubscribeFooter(html, plain, unsubscribeURL string) (string, string) {
|
||||
if HasUnsubscribeFooter(html) {
|
||||
if plain == "" {
|
||||
plain = stripTags(html)
|
||||
}
|
||||
return html, plain
|
||||
}
|
||||
footerHTML := `<hr style="border:none;border-top:1px solid #ddd;margin:24px 0"/>` +
|
||||
`<p style="font-size:12px;color:#666" ` + unsubscribeMark + `="1">` +
|
||||
`You are receiving this because you opted in to marketing emails. ` +
|
||||
`<a href="` + unsubscribeURL + `">Unsubscribe</a>.</p>`
|
||||
footerPlain := "\n\n---\nUnsubscribe: " + unsubscribeURL + "\n"
|
||||
if strings.TrimSpace(html) == "" {
|
||||
html = "<div></div>"
|
||||
}
|
||||
html = html + footerHTML
|
||||
if plain == "" {
|
||||
plain = stripTags(html)
|
||||
} else {
|
||||
plain = plain + footerPlain
|
||||
}
|
||||
return html, plain
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
if unicode.IsSpace(r) {
|
||||
if b.Len() > 0 && b.String()[b.Len()-1] != ' ' {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const productCursorVersion = 1
|
||||
|
||||
// productCursor is an opaque keyset bookmark for product list pages.
|
||||
// Encoded as URL-safe base64 JSON in the `cursor` query param.
|
||||
type productCursor struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
SB string `json:"sb"`
|
||||
SO string `json:"so"`
|
||||
K string `json:"k"`
|
||||
Blank bool `json:"b,omitempty"`
|
||||
}
|
||||
|
||||
// HasProductCursor reports whether the filter requests keyset pagination.
|
||||
func HasProductCursor(f ListFilter) bool {
|
||||
return strings.TrimSpace(f.Cursor) != "" || strings.TrimSpace(f.AfterID) != ""
|
||||
}
|
||||
|
||||
// EncodeProductCursor builds an opaque cursor from a product list row.
|
||||
func EncodeProductCursor(f ListFilter, item map[string]any) (string, error) {
|
||||
f = NormalizeListFilter(f)
|
||||
id := stringifyID(item["id"])
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("missing id")
|
||||
}
|
||||
cur := productCursor{
|
||||
V: productCursorVersion,
|
||||
ID: id,
|
||||
SB: f.SortBy,
|
||||
SO: f.SortOrder,
|
||||
}
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
name := productSortName(item)
|
||||
cur.Blank = name == ""
|
||||
cur.K = strings.ToLower(name)
|
||||
case "createdAt":
|
||||
ts, ok := asTime(item["created_at"])
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing created_at")
|
||||
}
|
||||
cur.K = ts.UTC().Format(time.RFC3339Nano)
|
||||
default: // updatedAt
|
||||
ts, ok := asTime(item["updated_at"])
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing updated_at")
|
||||
}
|
||||
cur.K = ts.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
raw, err := json.Marshal(cur)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// DecodeProductCursor parses an opaque product list cursor.
|
||||
func DecodeProductCursor(raw string) (productCursor, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return productCursor{}, fmt.Errorf("empty cursor")
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor encoding")
|
||||
}
|
||||
var cur productCursor
|
||||
if err := json.Unmarshal(b, &cur); err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor payload")
|
||||
}
|
||||
if cur.V != productCursorVersion {
|
||||
return productCursor{}, fmt.Errorf("unsupported cursor version")
|
||||
}
|
||||
if _, err := uuid.Parse(cur.ID); err != nil {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
switch cur.SB {
|
||||
case "name", "updatedAt", "createdAt":
|
||||
default:
|
||||
return productCursor{}, fmt.Errorf("invalid cursor sort")
|
||||
}
|
||||
if cur.SO != "asc" && cur.SO != "desc" {
|
||||
return productCursor{}, fmt.Errorf("invalid cursor order")
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
||||
// NextProductCursor returns next_cursor / next_after_id when the page is full.
|
||||
// When the page length equals limit there may still be no further rows (rare);
|
||||
// clients should treat an empty follow-up page as the end.
|
||||
func NextProductCursor(f ListFilter, items []map[string]any, limit int) (nextCursor, nextAfterID string) {
|
||||
f = NormalizeListFilter(f)
|
||||
if limit <= 0 || len(items) < limit {
|
||||
return "", ""
|
||||
}
|
||||
last := items[len(items)-1]
|
||||
nextAfterID = stringifyID(last["id"])
|
||||
enc, err := EncodeProductCursor(f, last)
|
||||
if err != nil {
|
||||
return "", nextAfterID
|
||||
}
|
||||
return enc, nextAfterID
|
||||
}
|
||||
|
||||
func (c productCursor) matchesFilter(f ListFilter) bool {
|
||||
f = NormalizeListFilter(f)
|
||||
return c.SB == f.SortBy && c.SO == f.SortOrder
|
||||
}
|
||||
|
||||
// appendRawKeyset adds a keyset predicate for raw_products (alias rp).
|
||||
func appendRawKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
|
||||
id, err := uuid.Parse(cur.ID)
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
dirAfter := keysetOp(f.SortOrder)
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
nameExpr := `COALESCE(NULLIF(rp.mapped_data->>'name', ''), NULLIF(rp.mapped_data->>'title', ''), '')`
|
||||
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
|
||||
args = append(args, boolToInt(cur.Blank), cur.K, id)
|
||||
b, k, i := len(args)-2, len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf(`(
|
||||
%s > $%d
|
||||
OR (%s = $%d AND LOWER(%s) %s $%d)
|
||||
OR (%s = $%d AND LOWER(%s) = $%d AND rp.id %s $%d)
|
||||
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
|
||||
return args, where, nil
|
||||
case "createdAt":
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(rp.created_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
default: // updatedAt
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(rp.updated_at, rp.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
}
|
||||
}
|
||||
|
||||
// appendProcessedKeyset adds a keyset predicate for processed_products (alias p).
|
||||
func appendProcessedKeyset(f ListFilter, cur productCursor, args []any, where []string) ([]any, []string, error) {
|
||||
id, err := uuid.Parse(cur.ID)
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor id")
|
||||
}
|
||||
dirAfter := keysetOp(f.SortOrder)
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
nameExpr := `COALESCE(NULLIF(p.processed_name, ''), NULLIF(p.name, ''), '')`
|
||||
blankExpr := fmt.Sprintf(`(CASE WHEN %s = '' THEN 1 ELSE 0 END)`, nameExpr)
|
||||
args = append(args, boolToInt(cur.Blank), cur.K, id)
|
||||
b, k, i := len(args)-2, len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf(`(
|
||||
%s > $%d
|
||||
OR (%s = $%d AND LOWER(%s) %s $%d)
|
||||
OR (%s = $%d AND LOWER(%s) = $%d AND p.id %s $%d)
|
||||
)`, blankExpr, b, blankExpr, b, nameExpr, dirAfter, k, blankExpr, b, nameExpr, k, dirAfter, i))
|
||||
return args, where, nil
|
||||
case "createdAt":
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(p.created_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
default: // updatedAt
|
||||
ts, err := time.Parse(time.RFC3339Nano, cur.K)
|
||||
if err != nil {
|
||||
ts, err = time.Parse(time.RFC3339, cur.K)
|
||||
}
|
||||
if err != nil {
|
||||
return args, where, fmt.Errorf("invalid cursor timestamp")
|
||||
}
|
||||
args = append(args, ts, id)
|
||||
a, b := len(args)-1, len(args)
|
||||
where = append(where, fmt.Sprintf("(p.updated_at, p.id) %s ($%d::timestamptz, $%d::uuid)", dirAfter, a, b))
|
||||
return args, where, nil
|
||||
}
|
||||
}
|
||||
|
||||
func keysetOp(sortOrder string) string {
|
||||
if sortOrder == "asc" {
|
||||
return ">"
|
||||
}
|
||||
return "<"
|
||||
}
|
||||
|
||||
func boolToInt(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func productSortName(item map[string]any) string {
|
||||
for _, key := range []string{"processed_name", "name"} {
|
||||
if s := strings.TrimSpace(stringifyID(item[key])); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringifyID(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case uuid.UUID:
|
||||
return t.String()
|
||||
case [16]byte:
|
||||
return uuid.UUID(t).String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
func asTime(v any) (time.Time, bool) {
|
||||
switch t := v.(type) {
|
||||
case time.Time:
|
||||
return t, true
|
||||
case *time.Time:
|
||||
if t == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return *t, true
|
||||
case string:
|
||||
if ts, err := time.Parse(time.RFC3339Nano, t); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
return ts, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// resolveRawCursor decodes cursor or loads sort keys for after_id on raw_products.
|
||||
// missing=true means after_id was not found for this company (caller should return an empty page).
|
||||
func (s *Service) resolveRawCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
|
||||
if c := strings.TrimSpace(f.Cursor); c != "" {
|
||||
cur, err = DecodeProductCursor(c)
|
||||
if err != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid cursor")
|
||||
}
|
||||
if !cur.matchesFilter(f) {
|
||||
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
|
||||
}
|
||||
return cur, true, false, nil
|
||||
}
|
||||
after := strings.TrimSpace(f.AfterID)
|
||||
if after == "" {
|
||||
return productCursor{}, false, false, nil
|
||||
}
|
||||
id, parseErr := uuid.Parse(after)
|
||||
if parseErr != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid after_id")
|
||||
}
|
||||
var name string
|
||||
var createdAt, updatedAt time.Time
|
||||
scanErr := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(NULLIF(mapped_data->>'name', ''), NULLIF(mapped_data->>'title', ''), ''),
|
||||
created_at, updated_at
|
||||
FROM raw_products
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &createdAt, &updatedAt)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, pgx.ErrNoRows) {
|
||||
return productCursor{}, true, true, nil
|
||||
}
|
||||
return productCursor{}, false, false, scanErr
|
||||
}
|
||||
cur = productCursorFromRow(f, id.String(), name, createdAt, updatedAt)
|
||||
return cur, true, false, nil
|
||||
}
|
||||
|
||||
// resolveProcessedCursor decodes cursor or loads sort keys for after_id on processed_products.
|
||||
func (s *Service) resolveProcessedCursor(ctx context.Context, companyID uuid.UUID, f ListFilter) (cur productCursor, use bool, missing bool, err error) {
|
||||
if c := strings.TrimSpace(f.Cursor); c != "" {
|
||||
cur, err = DecodeProductCursor(c)
|
||||
if err != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid cursor")
|
||||
}
|
||||
if !cur.matchesFilter(f) {
|
||||
return productCursor{}, false, false, ClientMsg("cursor sort mismatch")
|
||||
}
|
||||
return cur, true, false, nil
|
||||
}
|
||||
after := strings.TrimSpace(f.AfterID)
|
||||
if after == "" {
|
||||
return productCursor{}, false, false, nil
|
||||
}
|
||||
id, parseErr := uuid.Parse(after)
|
||||
if parseErr != nil {
|
||||
return productCursor{}, false, false, ClientMsg("invalid after_id")
|
||||
}
|
||||
var name, processedName string
|
||||
var createdAt, updatedAt time.Time
|
||||
scanErr := s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(name, ''), COALESCE(processed_name, ''), created_at, updated_at
|
||||
FROM processed_products
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID).Scan(&name, &processedName, &createdAt, &updatedAt)
|
||||
if scanErr != nil {
|
||||
if errors.Is(scanErr, pgx.ErrNoRows) {
|
||||
return productCursor{}, true, true, nil
|
||||
}
|
||||
return productCursor{}, false, false, scanErr
|
||||
}
|
||||
display := strings.TrimSpace(processedName)
|
||||
if display == "" {
|
||||
display = strings.TrimSpace(name)
|
||||
}
|
||||
cur = productCursorFromRow(f, id.String(), display, createdAt, updatedAt)
|
||||
return cur, true, false, nil
|
||||
}
|
||||
|
||||
func productCursorFromRow(f ListFilter, id, name string, createdAt, updatedAt time.Time) productCursor {
|
||||
cur := productCursor{
|
||||
V: productCursorVersion,
|
||||
ID: id,
|
||||
SB: f.SortBy,
|
||||
SO: f.SortOrder,
|
||||
}
|
||||
switch f.SortBy {
|
||||
case "name":
|
||||
cur.Blank = name == ""
|
||||
cur.K = strings.ToLower(name)
|
||||
case "createdAt":
|
||||
cur.K = createdAt.UTC().Format(time.RFC3339Nano)
|
||||
default:
|
||||
cur.K = updatedAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeProductCursorRoundTrip(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
|
||||
item := map[string]any{
|
||||
"id": "00000000-0000-4000-8000-000000000099",
|
||||
"updated_at": ts,
|
||||
}
|
||||
enc, err := EncodeProductCursor(f, item)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc == "" {
|
||||
t.Fatal("empty cursor")
|
||||
}
|
||||
cur, err := DecodeProductCursor(enc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur.ID != "00000000-0000-4000-8000-000000000099" {
|
||||
t.Fatalf("id=%q", cur.ID)
|
||||
}
|
||||
if cur.SB != "updatedAt" || cur.SO != "desc" {
|
||||
t.Fatalf("sort meta: %+v", cur)
|
||||
}
|
||||
if !strings.HasPrefix(cur.K, "2026-08-04T12:00:00") {
|
||||
t.Fatalf("key=%q", cur.K)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextProductCursor(t *testing.T) {
|
||||
f := ListFilter{SortBy: "updatedAt", SortOrder: "desc"}
|
||||
items := []map[string]any{
|
||||
{"id": "00000000-0000-4000-8000-000000000001", "updated_at": time.Now().UTC()},
|
||||
{"id": "00000000-0000-4000-8000-000000000002", "updated_at": time.Now().UTC()},
|
||||
}
|
||||
next, after := NextProductCursor(f, items, 2)
|
||||
if next == "" || after != "00000000-0000-4000-8000-000000000002" {
|
||||
t.Fatalf("next=%q after=%q", next, after)
|
||||
}
|
||||
none, noneAfter := NextProductCursor(f, items[:1], 2)
|
||||
if none != "" || noneAfter != "" {
|
||||
t.Fatalf("short page should end: next=%q after=%q", none, noneAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasProductCursorClearsOffset(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{Offset: 500, AfterID: "00000000-0000-4000-8000-000000000001"})
|
||||
if !HasProductCursor(f) {
|
||||
t.Fatal("expected cursor")
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("offset should clear with cursor: %d", f.Offset)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ecommerceGroupDef struct {
|
||||
Key string
|
||||
Name string
|
||||
Description string
|
||||
Order int
|
||||
}
|
||||
|
||||
type ecommerceFieldDef struct {
|
||||
Key string
|
||||
Name string
|
||||
Type string
|
||||
GroupKey string
|
||||
Required bool
|
||||
Enabled bool
|
||||
Recommended bool
|
||||
Unit string
|
||||
DefaultValue string
|
||||
SortOrder int
|
||||
Hints []string
|
||||
Description string
|
||||
}
|
||||
|
||||
func ecommerceGroups() []ecommerceGroupDef {
|
||||
return []ecommerceGroupDef{
|
||||
{Key: "basic", Name: "Basic Information", Description: "Core product identifiers and content", Order: 10},
|
||||
{Key: "pricing", Name: "Pricing", Description: "Price and currency fields", Order: 20},
|
||||
{Key: "media", Name: "Media", Description: "Images and media URLs", Order: 30},
|
||||
{Key: "taxonomy", Name: "Taxonomy", Description: "Categories and classification", Order: 40},
|
||||
{Key: "inventory", Name: "Inventory", Description: "Stock and availability", Order: 50},
|
||||
{Key: "attributes", Name: "Attributes", Description: "Variant and product attributes", Order: 60},
|
||||
{Key: "shipping", Name: "Shipping", Description: "Weight and dimensions", Order: 70},
|
||||
}
|
||||
}
|
||||
|
||||
func ecommerceFields() []ecommerceFieldDef {
|
||||
return []ecommerceFieldDef{
|
||||
{Key: "gtin", Name: "GTIN/EAN", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"ean", "upc", "barcode", "gtin13"}, Description: "Product barcode"},
|
||||
{Key: "title", Name: "Product name", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"name", "product_name", "product_title"}, Description: "Primary product title"},
|
||||
{Key: "brand", Name: "Brand", Type: "string", GroupKey: "basic", Required: true, Enabled: true, Recommended: true, SortOrder: 30, Hints: []string{"manufacturer", "vendor"}, Description: "Brand or manufacturer"},
|
||||
{Key: "description", Name: "Description", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 40, Hints: []string{"desc", "body", "long_description"}, Description: "Product description"},
|
||||
{Key: "sku", Name: "SKU", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 50, Hints: []string{"item_sku", "article_number"}, Description: "Stock keeping unit"},
|
||||
{Key: "mpn", Name: "MPN", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"manufacturer_part_number", "part_number"}, Description: "Manufacturer part number"},
|
||||
{Key: "product_model", Name: "Product model", Type: "string", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 65, Hints: []string{"model", "productmodel", "product_model"}, Description: "Manufacturer model name/number"},
|
||||
{Key: "product_url", Name: "Product URL", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"url", "link", "product_link"}, Description: "Canonical product page URL"},
|
||||
{Key: "official_link", Name: "Official link", Type: "url", GroupKey: "basic", Required: false, Enabled: true, Recommended: true, SortOrder: 80, Hints: []string{"officiallink", "manufacturer_url"}, Description: "Manufacturer or brand product page"},
|
||||
{Key: "price", Name: "Price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Unit: "EUR", Hints: []string{"regular_price", "list_price", "amount"}, Description: "Regular price"},
|
||||
{Key: "sale_price", Name: "Sale price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Unit: "EUR", Hints: []string{"special_price", "discount_price"}, Description: "Promotional price"},
|
||||
{Key: "purchase_price", Name: "Purchase price", Type: "number", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 25, Unit: "EUR", Hints: []string{"purchaseprice", "cost", "buy_price"}, Description: "Cost / buy price"},
|
||||
{Key: "currency", Name: "Currency", Type: "string", GroupKey: "pricing", Required: false, Enabled: true, Recommended: true, SortOrder: 30, DefaultValue: "EUR", Hints: []string{"price_currency", "curr"}, Description: "ISO currency code"},
|
||||
{Key: "image_url", Name: "Image URL", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"image", "image_link", "thumbnail"}, Description: "Primary product image"},
|
||||
{Key: "main_image", Name: "Main image", Type: "image", GroupKey: "media", Required: true, Enabled: true, Recommended: true, SortOrder: 15, Hints: []string{"mainimage", "image", "image_url"}, Description: "Main gallery image URL"},
|
||||
{Key: "additional_image_urls", Name: "Additional images", Type: "image", GroupKey: "media", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"images", "gallery", "moreimages", "additional_images"}, Description: "Extra product images"},
|
||||
{Key: "video_url", Name: "Video URL", Type: "url", GroupKey: "media", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"videourl", "video"}, Description: "Product video URL"},
|
||||
{Key: "category", Name: "Category", Type: "string", GroupKey: "taxonomy", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"product_type", "google_product_category", "category_path"}, Description: "Product category"},
|
||||
{Key: "availability", Name: "Availability", Type: "string", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"in_stock", "stock_status", "stockstatus"}, Description: "Availability status"},
|
||||
{Key: "stock", Name: "Stock", Type: "number", GroupKey: "inventory", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"quantity", "qty", "inventory"}, Description: "Stock quantity"},
|
||||
{Key: "color", Name: "Color", Type: "color", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 10, Hints: []string{"colour", "farbe"}, Description: "Color attribute"},
|
||||
{Key: "size", Name: "Size", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 20, Hints: []string{"groesse", "dimension_size"}, Description: "Size attribute"},
|
||||
{Key: "material", Name: "Material", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"fabric", "composition"}, Description: "Material attribute"},
|
||||
{Key: "warranty", Name: "Warranty", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"guarantee"}, Description: "Warranty term or text"},
|
||||
{Key: "service", Name: "Service", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{}, Description: "Service or support notes"},
|
||||
{Key: "specifications", Name: "Specifications", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 60, Hints: []string{"specs", "specification"}, Description: "Technical specifications"},
|
||||
{Key: "eprel_id", Name: "EPREL ID", Type: "string", GroupKey: "attributes", Required: false, Enabled: true, Recommended: true, SortOrder: 70, Hints: []string{"eprelid", "eprel"}, Description: "EU energy label identifier"},
|
||||
{Key: "weight", Name: "Weight", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 10, Unit: "kg", Hints: []string{"shipping_weight", "product_weight", "netmass"}, Description: "Product weight"},
|
||||
{Key: "net_depth", Name: "Net depth", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 20, Hints: []string{"netdepth", "depth"}, Description: "Net depth"},
|
||||
{Key: "net_height", Name: "Net height", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 30, Hints: []string{"netheight", "height"}, Description: "Net height"},
|
||||
{Key: "net_width", Name: "Net width", Type: "dimension", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 40, Hints: []string{"netwidth", "width"}, Description: "Net width"},
|
||||
{Key: "net_mass", Name: "Net mass", Type: "weight", GroupKey: "shipping", Required: false, Enabled: true, Recommended: false, SortOrder: 50, Hints: []string{"netmass", "mass"}, Description: "Net mass"},
|
||||
}
|
||||
}
|
||||
|
||||
func recommendedEcommerceKeys() []string {
|
||||
out := make([]string, 0)
|
||||
for _, f := range ecommerceFields() {
|
||||
if f.Recommended {
|
||||
out = append(out, f.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) ensureGroupID(ctx context.Context, companyID uuid.UUID, g ecommerceGroupDef) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
SELECT id FROM field_groups WHERE company_id = $1 AND name = $2 LIMIT 1`,
|
||||
companyID, g.Name).Scan(&id)
|
||||
if err == nil {
|
||||
_, _ = s.Pool.Exec(ctx, `
|
||||
UPDATE field_groups SET description = $3, "order" = $4, is_system = true, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID, g.Description, g.Order)
|
||||
return id, nil
|
||||
}
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO field_groups (company_id, name, description, "order", is_system)
|
||||
VALUES ($1, $2, $3, $4, true) RETURNING id`,
|
||||
companyID, g.Name, g.Description, g.Order).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// EnsureEcommerceCatalog upserts system field groups and standard fields for ecommerce.
|
||||
func (s *Service) EnsureEcommerceCatalog(ctx context.Context, companyID uuid.UUID) error {
|
||||
groupIDs := map[string]uuid.UUID{}
|
||||
for _, g := range ecommerceGroups() {
|
||||
id, err := s.ensureGroupID(ctx, companyID, g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs[g.Key] = id
|
||||
}
|
||||
|
||||
for _, f := range ecommerceFields() {
|
||||
gid, ok := groupIDs[f.GroupKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
hints, _ := json.Marshal(f.Hints)
|
||||
var defVal *string
|
||||
if f.DefaultValue != "" {
|
||||
v := f.DefaultValue
|
||||
defVal = &v
|
||||
}
|
||||
var unit *string
|
||||
if f.Unit != "" {
|
||||
u := f.Unit
|
||||
unit = &u
|
||||
}
|
||||
var desc *string
|
||||
if f.Description != "" {
|
||||
d := f.Description
|
||||
desc = &d
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO standard_fields (
|
||||
company_id, name, key, type, group_id, is_required, description, default_value,
|
||||
is_system, enabled, unit, sort_order, mapping_hints
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,true,$9,$10,$11,$12::jsonb)
|
||||
ON CONFLICT (company_id, key) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
type = EXCLUDED.type,
|
||||
group_id = EXCLUDED.group_id,
|
||||
is_required = standard_fields.is_required OR EXCLUDED.is_required,
|
||||
description = COALESCE(EXCLUDED.description, standard_fields.description),
|
||||
default_value = COALESCE(standard_fields.default_value, EXCLUDED.default_value),
|
||||
unit = COALESCE(standard_fields.unit, EXCLUDED.unit),
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
enabled = standard_fields.enabled OR EXCLUDED.enabled,
|
||||
mapping_hints = CASE
|
||||
WHEN standard_fields.mapping_hints IS NULL
|
||||
OR standard_fields.mapping_hints = '[]'::jsonb
|
||||
THEN EXCLUDED.mapping_hints
|
||||
ELSE standard_fields.mapping_hints
|
||||
END,
|
||||
updated_at = now()`,
|
||||
companyID, f.Name, f.Key, f.Type, gid, f.Required, desc, defVal,
|
||||
f.Enabled, unit, f.SortOrder, string(hints))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package catalog
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSystemImmutable = errors.New("system records cannot be modified or deleted")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// clientError is a validation/business message safe to return to API clients.
|
||||
type clientError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *clientError) Error() string { return e.msg }
|
||||
|
||||
// ClientMsg marks a message as safe to expose in HTTP 4xx responses.
|
||||
func ClientMsg(msg string) error {
|
||||
return &clientError{msg: msg}
|
||||
}
|
||||
|
||||
// ClientError reports whether err is a known client-facing catalog error.
|
||||
func ClientError(err error) (msg string, ok bool) {
|
||||
if err == nil {
|
||||
return "", false
|
||||
}
|
||||
var ce *clientError
|
||||
if errors.As(err, &ce) {
|
||||
return ce.msg, true
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return "not found", true
|
||||
case errors.Is(err, ErrSystemImmutable):
|
||||
return err.Error(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const maxUploadBytes = 5 << 20 // 5 MiB
|
||||
|
||||
func sanitizeFileName(name string) string {
|
||||
name = filepath.Base(strings.TrimSpace(name))
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return "upload.csv"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
return "upload.csv"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) SaveUpload(ctx context.Context, companyID, userID uuid.UUID, uploadDir, originalName, contentType, kind string, r io.Reader) (map[string]any, error) {
|
||||
uploadDir = strings.TrimSpace(uploadDir)
|
||||
if uploadDir == "" {
|
||||
return nil, ClientMsg("upload directory not configured")
|
||||
}
|
||||
safe := sanitizeFileName(originalName)
|
||||
lower := strings.ToLower(safe)
|
||||
if !strings.HasSuffix(lower, ".csv") {
|
||||
return nil, ClientMsg("only .csv uploads are allowed")
|
||||
}
|
||||
if contentType != "" &&
|
||||
!strings.Contains(strings.ToLower(contentType), "csv") &&
|
||||
!strings.Contains(strings.ToLower(contentType), "text/plain") &&
|
||||
!strings.Contains(strings.ToLower(contentType), "octet-stream") {
|
||||
return nil, ClientMsg("invalid content type for CSV upload")
|
||||
}
|
||||
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if kind == "" {
|
||||
kind = "products"
|
||||
}
|
||||
metaBytes, _ := json.Marshal(map[string]any{"kind": kind})
|
||||
|
||||
fileID := uuid.New()
|
||||
dir := filepath.Join(uploadDir, companyID.String())
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rel := filepath.ToSlash(filepath.Join(companyID.String(), fileID.String()+"-"+safe))
|
||||
abs := filepath.Join(uploadDir, filepath.FromSlash(rel))
|
||||
|
||||
f, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, io.LimitReader(r, maxUploadBytes+1))
|
||||
if err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, err
|
||||
}
|
||||
if n > maxUploadBytes {
|
||||
_ = os.Remove(abs)
|
||||
return nil, ClientMsg(fmt.Sprintf("file exceeds %d byte limit", maxUploadBytes))
|
||||
}
|
||||
|
||||
var uid any
|
||||
if userID != uuid.Nil {
|
||||
uid = userID
|
||||
}
|
||||
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO files (id, company_id, user_id, name, path, content_type, size_bytes, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploaded', $8::jsonb)
|
||||
RETURNING id`, fileID, companyID, uid, safe, rel, contentType, n, string(metaBytes)).Scan(&id)
|
||||
if err != nil {
|
||||
_ = os.Remove(abs)
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": id.String(),
|
||||
"name": safe,
|
||||
"path": rel,
|
||||
"content_type": contentType,
|
||||
"size_bytes": n,
|
||||
"status": "uploaded",
|
||||
"kind": kind,
|
||||
"metadata": map[string]any{"kind": kind},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResolveUploadPath(uploadDir string, companyID uuid.UUID, rel string) (string, error) {
|
||||
uploadDir = strings.TrimSpace(uploadDir)
|
||||
if uploadDir == "" {
|
||||
return "", ClientMsg("upload directory not configured")
|
||||
}
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
if rel == "" || strings.Contains(rel, "..") {
|
||||
return "", ClientMsg("invalid path")
|
||||
}
|
||||
prefix := companyID.String() + "/"
|
||||
if !strings.HasPrefix(rel, prefix) {
|
||||
return "", ClientMsg("forbidden")
|
||||
}
|
||||
base, err := filepath.Abs(uploadDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
abs, err := filepath.Abs(filepath.Join(uploadDir, filepath.FromSlash(rel)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sep := string(os.PathSeparator)
|
||||
if abs != base && !strings.HasPrefix(abs, base+sep) {
|
||||
return "", ClientMsg("forbidden")
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func scanFileRow(rows pgx.Row) (map[string]any, error) {
|
||||
var (
|
||||
id uuid.UUID
|
||||
companyID uuid.UUID
|
||||
userID *uuid.UUID
|
||||
name string
|
||||
path *string
|
||||
contentType *string
|
||||
sizeBytes int64
|
||||
status string
|
||||
metadata []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &companyID, &userID, &name, &path, &contentType, &sizeBytes, &status, &metadata, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var meta any = map[string]any{}
|
||||
if len(metadata) > 0 {
|
||||
_ = json.Unmarshal(metadata, &meta)
|
||||
}
|
||||
out := map[string]any{
|
||||
"id": id.String(),
|
||||
"company_id": companyID.String(),
|
||||
"name": name,
|
||||
"size_bytes": sizeBytes,
|
||||
"status": status,
|
||||
"metadata": meta,
|
||||
"created_at": createdAt.UTC().Format(time.RFC3339),
|
||||
"updated_at": updatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if userID != nil {
|
||||
out["user_id"] = userID.String()
|
||||
}
|
||||
if path != nil {
|
||||
out["path"] = *path
|
||||
}
|
||||
if contentType != nil {
|
||||
out["content_type"] = *contentType
|
||||
}
|
||||
if m, ok := meta.(map[string]any); ok {
|
||||
if k, ok := m["kind"].(string); ok {
|
||||
out["kind"] = k
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListFiles(ctx context.Context, companyID uuid.UUID, f ListFilter) ([]map[string]any, int, error) {
|
||||
f = NormalizeListFilter(f)
|
||||
var total int
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM files WHERE company_id = $1`, companyID).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
|
||||
FROM files
|
||||
WHERE company_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, companyID, f.Limit, f.Offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]map[string]any, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanFileRow(rows)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetFile(ctx context.Context, companyID, fileID uuid.UUID) (map[string]any, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, company_id, user_id, name, path, content_type, size_bytes, status, metadata, created_at, updated_at
|
||||
FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
|
||||
item, err := scanFileRow(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) UpdateFileStatus(ctx context.Context, companyID, fileID uuid.UUID, status string, metadata map[string]any) (map[string]any, error) {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
switch status {
|
||||
case "uploaded", "processing", "completed", "failed":
|
||||
default:
|
||||
return nil, ClientMsg("invalid file status")
|
||||
}
|
||||
metaBytes := []byte("{}")
|
||||
if metadata != nil {
|
||||
b, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
metaBytes = b
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
UPDATE files
|
||||
SET status = $3,
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || $4::jsonb,
|
||||
updated_at = now()
|
||||
WHERE company_id = $1 AND id = $2`, companyID, fileID, status, string(metaBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetFile(ctx, companyID, fileID)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteFile(ctx context.Context, companyID, fileID uuid.UUID, uploadDir string) error {
|
||||
item, err := s.GetFile(ctx, companyID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM files WHERE company_id = $1 AND id = $2`, companyID, fileID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return pgx.ErrNoRows
|
||||
}
|
||||
if pathStr, ok := item["path"].(string); ok && pathStr != "" {
|
||||
if abs, err := s.ResolveUploadPath(uploadDir, companyID, pathStr); err == nil {
|
||||
_ = os.Remove(abs)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestResolveUploadPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := t.TempDir()
|
||||
cid := uuid.New()
|
||||
rel := cid.String() + "/sample.csv"
|
||||
absWant := filepath.Join(base, filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(absWant), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(absWant, []byte("a,b\n1,2\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc := &Service{}
|
||||
got, err := svc.ResolveUploadPath(base, cid, rel)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if filepath.Clean(got) != filepath.Clean(absWant) {
|
||||
t.Fatalf("got %q want %q", got, absWant)
|
||||
}
|
||||
|
||||
if _, err := svc.ResolveUploadPath("", cid, rel); err == nil {
|
||||
t.Fatal("expected empty upload dir reject")
|
||||
}
|
||||
if _, err := svc.ResolveUploadPath(base, cid, "../etc/passwd"); err == nil {
|
||||
t.Fatal("expected traversal reject")
|
||||
}
|
||||
if _, err := svc.ResolveUploadPath(base, cid, cid.String()+"/../outside.csv"); err == nil {
|
||||
t.Fatal("expected nested traversal reject")
|
||||
}
|
||||
other := uuid.New()
|
||||
if _, err := svc.ResolveUploadPath(base, cid, other.String()+"/x.csv"); err == nil {
|
||||
t.Fatal("expected company mismatch reject")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeListFilterDefaults(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{})
|
||||
if f.Limit != 50 {
|
||||
t.Fatalf("default limit: got %d", f.Limit)
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("default offset: got %d", f.Offset)
|
||||
}
|
||||
if f.SortBy != "updatedAt" {
|
||||
t.Fatalf("default sortBy: got %q", f.SortBy)
|
||||
}
|
||||
if f.SortOrder != "desc" {
|
||||
t.Fatalf("default sortOrder: got %q", f.SortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeListFilterCaps(t *testing.T) {
|
||||
f := NormalizeListFilter(ListFilter{Limit: 9000, Offset: -3, Query: " abc ", SortBy: "bogus", SortOrder: "ASC"})
|
||||
if f.Limit != 2000 {
|
||||
t.Fatalf("cap limit: got %d", f.Limit)
|
||||
}
|
||||
if f.Offset != 0 {
|
||||
t.Fatalf("offset floor: got %d", f.Offset)
|
||||
}
|
||||
if f.Query != "abc" {
|
||||
t.Fatalf("query trim: got %q", f.Query)
|
||||
}
|
||||
if f.SortBy != "updatedAt" {
|
||||
t.Fatalf("invalid sortBy fallback: got %q", f.SortBy)
|
||||
}
|
||||
if f.SortOrder != "asc" {
|
||||
t.Fatalf("sortOrder normalize: got %q", f.SortOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProductListFilterKeysetEnforcement(t *testing.T) {
|
||||
ok, err := normalizeProductListFilter(ListFilter{Limit: 9000, Offset: MaxOffsetWithoutCursor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok.Limit != MaxProductPageLimit {
|
||||
t.Fatalf("product limit cap: got %d", ok.Limit)
|
||||
}
|
||||
if ok.Offset != MaxOffsetWithoutCursor {
|
||||
t.Fatalf("offset at cap should pass: got %d", ok.Offset)
|
||||
}
|
||||
|
||||
_, err = normalizeProductListFilter(ListFilter{Offset: MaxOffsetWithoutCursor + 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected deep offset rejected")
|
||||
}
|
||||
if msg, ok := ClientError(err); !ok || !strings.Contains(msg, "cursor") {
|
||||
t.Fatalf("client msg: %v", err)
|
||||
}
|
||||
|
||||
cur, err := normalizeProductListFilter(ListFilter{
|
||||
Offset: MaxOffsetWithoutCursor + 50,
|
||||
AfterID: "00000000-0000-4000-8000-000000000001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cur.Offset != 0 {
|
||||
t.Fatalf("cursor should clear offset: %d", cur.Offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRawProductFiltersSearchShape(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"rp.company_id = $1"}
|
||||
args, where = appendRawProductFilters(ListFilter{
|
||||
Query: " widget ",
|
||||
Status: "unprocessed",
|
||||
FeedID: "00000000-0000-4000-8000-000000000001",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("args len=%d want 4 (company, query, status, feed)", len(args))
|
||||
}
|
||||
if got, ok := args[1].(string); !ok || got != "% widget %" {
|
||||
// Query is not trimmed here — callers NormalizeListFilter first.
|
||||
t.Fatalf("query bind: %#v", args[1])
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
for _, need := range []string{
|
||||
"rp.gtin ILIKE",
|
||||
"mapped_data->>'name'",
|
||||
"mapped_data->>'title'",
|
||||
"f.name",
|
||||
"rp.processing_status = $3",
|
||||
"rp.feed_id = $4",
|
||||
} {
|
||||
if !strings.Contains(wSQL, need) {
|
||||
t.Fatalf("missing %q in %q", need, wSQL)
|
||||
}
|
||||
}
|
||||
for _, banned := range []string{
|
||||
"CAST(rp.mapped_data AS text)",
|
||||
"CAST(rp.raw_data AS text)",
|
||||
"attributes",
|
||||
"@>",
|
||||
} {
|
||||
if strings.Contains(wSQL, banned) {
|
||||
t.Fatalf("unexpected %q in %q", banned, wSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedProductFiltersSearchShape(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Query: "sku-1",
|
||||
Status: "published",
|
||||
Category: "cat-a",
|
||||
FeedID: "00000000-0000-4000-8000-000000000002",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 5 {
|
||||
t.Fatalf("args len=%d want 5", len(args))
|
||||
}
|
||||
if got, ok := args[1].(string); !ok || got != "%sku-1%" {
|
||||
t.Fatalf("query bind: %#v", args[1])
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
for _, need := range []string{
|
||||
"p.name ILIKE",
|
||||
"processed_name",
|
||||
"p.product_id ILIKE",
|
||||
"p.category ILIKE",
|
||||
"r.gtin",
|
||||
"p.status = $3",
|
||||
"p.category = $4",
|
||||
"EXISTS (",
|
||||
"p.feed_id = $5",
|
||||
} {
|
||||
if !strings.Contains(wSQL, need) {
|
||||
t.Fatalf("missing %q in %q", need, wSQL)
|
||||
}
|
||||
}
|
||||
// Product JSON attributes are returned by detailed list APIs, not filtered in SQL.
|
||||
for _, banned := range []string{
|
||||
"p.attributes",
|
||||
"processed_attributes",
|
||||
"CAST(",
|
||||
"@>",
|
||||
} {
|
||||
if strings.Contains(wSQL, banned) {
|
||||
t.Fatalf("unexpected %q in %q", banned, wSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCoverageFilter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"all": "",
|
||||
"Complete": "complete",
|
||||
"partial": "incomplete",
|
||||
"missing-attributes": "missing_attributes",
|
||||
"attrs": "missing_attributes",
|
||||
"name": "missing_name",
|
||||
"bogus": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeCoverageFilter(in); got != want {
|
||||
t.Fatalf("normalizeCoverageFilter(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEprelFilter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"all": "",
|
||||
"has_eprel": "has_eprel",
|
||||
"with-eprel": "has_eprel",
|
||||
"no_eprel": "no_eprel",
|
||||
"missing_eprel": "no_eprel",
|
||||
"bogus": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeEprelFilter(in); got != want {
|
||||
t.Fatalf("normalizeEprelFilter(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedEprelFilter(t *testing.T) {
|
||||
where := appendProcessedEprelFilter("has_eprel", []string{"p.company_id = $1"})
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "eprel_id") {
|
||||
t.Fatalf("expected eprel predicate in %q", wSQL)
|
||||
}
|
||||
if !processedListNeedsRawJoin(ProductFilter{Eprel: "has_eprel"}) {
|
||||
t.Fatal("eprel filter must force raw join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedCoverageFilter(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Coverage: "missing_attributes",
|
||||
}, args, where)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("coverage should not bind args; got %d", len(args))
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "NOT") || !strings.Contains(wSQL, "processed_attributes") {
|
||||
t.Fatalf("expected missing attributes predicate in %q", wSQL)
|
||||
}
|
||||
if !processedListNeedsRawJoin(ProductFilter{Coverage: "incomplete"}) {
|
||||
t.Fatal("coverage filter must force raw join for count")
|
||||
}
|
||||
if processedListNeedsRawJoin(ProductFilter{}) {
|
||||
t.Fatal("empty filter should not force raw join")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendProcessedProductFiltersNeedsReviewAlias(t *testing.T) {
|
||||
args := []any{"company"}
|
||||
where := []string{"p.company_id = $1"}
|
||||
args, where = appendProcessedProductFilters(ProductFilter{
|
||||
Status: "needs_review",
|
||||
}, args, where)
|
||||
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("needs_review should not bind status arg; args len=%d want 1", len(args))
|
||||
}
|
||||
wSQL := strings.Join(where, " AND ")
|
||||
if !strings.Contains(wSQL, "p.status IN ('needs_review', 'processed')") {
|
||||
t.Fatalf("expected legacy processed alias in %q", wSQL)
|
||||
}
|
||||
|
||||
args2 := []any{"company"}
|
||||
where2 := []string{"p.company_id = $1"}
|
||||
args2, where2 = appendProcessedProductFilters(ProductFilter{
|
||||
Status: "completed",
|
||||
}, args2, where2)
|
||||
if len(args2) != 2 {
|
||||
t.Fatalf("completed should bind status; args len=%d want 2", len(args2))
|
||||
}
|
||||
w2 := strings.Join(where2, " AND ")
|
||||
if !strings.Contains(w2, "p.status = $2") {
|
||||
t.Fatalf("expected exact completed filter in %q", w2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawAndProcessedCountFromSQL(t *testing.T) {
|
||||
rawJoin := rawProductsCountFromSQL(true)
|
||||
rawPlain := rawProductsCountFromSQL(false)
|
||||
if !strings.Contains(rawJoin, "LEFT JOIN input_feeds") {
|
||||
t.Fatalf("raw search count needs feed join: %q", rawJoin)
|
||||
}
|
||||
if strings.Contains(rawPlain, "LEFT JOIN") {
|
||||
t.Fatalf("raw count without search should skip feed join: %q", rawPlain)
|
||||
}
|
||||
procJoin := processedProductsCountFromSQL(true)
|
||||
procPlain := processedProductsCountFromSQL(false)
|
||||
if !strings.Contains(procJoin, "LEFT JOIN raw_products") {
|
||||
t.Fatalf("processed search count needs raw join: %q", procJoin)
|
||||
}
|
||||
if strings.Contains(procPlain, "LEFT JOIN") {
|
||||
t.Fatalf("processed count without search should skip raw join: %q", procPlain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawProductsOrderBy(t *testing.T) {
|
||||
created := rawProductsOrderBy(ListFilter{SortBy: "createdAt", SortOrder: "desc"})
|
||||
if !strings.Contains(created, "rp.created_at DESC") {
|
||||
t.Fatalf("createdAt order: %q", created)
|
||||
}
|
||||
updated := rawProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "asc"})
|
||||
if !strings.Contains(updated, "rp.updated_at ASC") {
|
||||
t.Fatalf("updatedAt order: %q", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessedProductsOrderBy(t *testing.T) {
|
||||
ascName := processedProductsOrderBy(ListFilter{SortBy: "name", SortOrder: "asc"})
|
||||
if !strings.Contains(ascName, "processed_name") || !strings.Contains(ascName, "ASC") {
|
||||
t.Fatalf("name asc order: %q", ascName)
|
||||
}
|
||||
if !strings.Contains(ascName, "THEN 1 ELSE 0") {
|
||||
t.Fatalf("expected blank names last: %q", ascName)
|
||||
}
|
||||
descUpdated := processedProductsOrderBy(ListFilter{SortBy: "updatedAt", SortOrder: "desc"})
|
||||
if !strings.Contains(descUpdated, "p.updated_at DESC") {
|
||||
t.Fatalf("updated desc order: %q", descUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExactTotalFromPage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
offset, limit int
|
||||
pageLen int
|
||||
wantTotal int64
|
||||
wantOK bool
|
||||
}{
|
||||
{name: "empty first page", offset: 0, limit: 50, pageLen: 0, wantTotal: 0, wantOK: true},
|
||||
{name: "short first page", offset: 0, limit: 50, pageLen: 12, wantTotal: 12, wantOK: true},
|
||||
{name: "full first page", offset: 0, limit: 50, pageLen: 50, wantOK: false},
|
||||
{name: "short later page", offset: 100, limit: 50, pageLen: 3, wantTotal: 103, wantOK: true},
|
||||
{name: "empty later page", offset: 100, limit: 50, pageLen: 0, wantOK: false},
|
||||
{name: "invalid limit", offset: 0, limit: 0, pageLen: 0, wantOK: false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, ok := exactTotalFromPage(c.offset, c.limit, c.pageLen)
|
||||
if ok != c.wantOK {
|
||||
t.Fatalf("ok=%v want %v", ok, c.wantOK)
|
||||
}
|
||||
if ok && got != c.wantTotal {
|
||||
t.Fatalf("total=%d want %d", got, c.wantTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCountAndList(t *testing.T) {
|
||||
items, total, err := parallelCountAndList(context.Background(),
|
||||
1, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return 42, nil
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return []map[string]any{{"id": "a"}}, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 42 || len(items) != 1 {
|
||||
t.Fatalf("total=%d items=%d", total, len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCountAndListSkipsCountOnShortPage(t *testing.T) {
|
||||
countCalls := 0
|
||||
items, total, err := parallelCountAndList(context.Background(),
|
||||
50, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
countCalls++
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return 0, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return 999, nil
|
||||
}
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "a"}, {"id": "b"}}, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(items) != 2 {
|
||||
t.Fatalf("total=%d items=%d", total, len(items))
|
||||
}
|
||||
// Count may have started; short-page path must not wait on / require its success.
|
||||
_ = countCalls
|
||||
}
|
||||
|
||||
func TestParallelCountAndListPropagatesErrors(t *testing.T) {
|
||||
_, _, err := parallelCountAndList(context.Background(),
|
||||
1, 0,
|
||||
func(ctx context.Context) (int64, error) {
|
||||
return 0, errors.New("count failed")
|
||||
},
|
||||
func(ctx context.Context) ([]map[string]any, error) {
|
||||
return []map[string]any{{"id": "a"}}, nil
|
||||
},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "count failed") {
|
||||
t.Fatalf("expected count error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeaderIndex(t *testing.T) {
|
||||
headers := []string{"Name", "unique_id", "GTIN"}
|
||||
if headerIndex(headers, "unique_id", "id") != 1 {
|
||||
t.Fatal("expected unique_id at 1")
|
||||
}
|
||||
if headerIndex(headers, "gtin", "ean") != 2 {
|
||||
t.Fatal("expected gtin at 2")
|
||||
}
|
||||
if headerIndex(headers, "missing") != -1 {
|
||||
t.Fatal("expected missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFileName(t *testing.T) {
|
||||
if sanitizeFileName("../evil.csv") != "evil.csv" {
|
||||
t.Fatalf("got %q", sanitizeFileName("../evil.csv"))
|
||||
}
|
||||
if sanitizeFileName("a b*.csv") != "a_b_.csv" {
|
||||
t.Fatalf("got %q", sanitizeFileName("a b*.csv"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,830 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
importCSVBatchSize = 500
|
||||
importCSVMaxErrors = 50
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func (r *ImportResult) addError(msg string) {
|
||||
if len(r.Errors) >= importCSVMaxErrors {
|
||||
return
|
||||
}
|
||||
r.Errors = append(r.Errors, msg)
|
||||
}
|
||||
|
||||
func headerIndex(headers []string, names ...string) int {
|
||||
want := map[string]struct{}{}
|
||||
for _, n := range names {
|
||||
want[strings.ToLower(strings.TrimSpace(n))] = struct{}{}
|
||||
}
|
||||
for i, h := range headers {
|
||||
if _, ok := want[strings.ToLower(strings.TrimSpace(h))]; ok {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func cell(row []string, idx int) string {
|
||||
if idx < 0 || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[idx])
|
||||
}
|
||||
|
||||
type categoryCSVRow struct {
|
||||
name string
|
||||
uniqueID string
|
||||
parent *string
|
||||
desc *string
|
||||
}
|
||||
|
||||
type categoryPathInfo struct {
|
||||
id uuid.UUID
|
||||
path string
|
||||
level int
|
||||
}
|
||||
|
||||
func (s *Service) ImportCategoriesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iName := headerIndex(headers, "name")
|
||||
iUID := headerIndex(headers, "unique_id", "id", "category_id")
|
||||
iParent := headerIndex(headers, "parent_unique_id", "parent_id", "parent")
|
||||
iDesc := headerIndex(headers, "description")
|
||||
if iName < 0 || iUID < 0 {
|
||||
return res, ClientMsg("CSV must include name and unique_id columns")
|
||||
}
|
||||
|
||||
known := map[string]categoryPathInfo{}
|
||||
batch := make([]categoryCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
name := cell(row, iName)
|
||||
uid := cell(row, iUID)
|
||||
if name == "" || uid == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
var parent *string
|
||||
if p := cell(row, iParent); p != "" {
|
||||
parent = &p
|
||||
}
|
||||
var desc *string
|
||||
if d := cell(row, iDesc); d != "" {
|
||||
desc = &d
|
||||
}
|
||||
batch = append(batch, categoryCSVRow{name: name, uniqueID: uid, parent: parent, desc: desc})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushCategoryBatch(ctx, companyID, batch, known, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushCategoryBatch(ctx context.Context, companyID uuid.UUID, batch []categoryCSVRow, known map[string]categoryPathInfo, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byUID := make(map[string]categoryCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byUID[row.uniqueID]; !ok {
|
||||
order = append(order, row.uniqueID)
|
||||
}
|
||||
byUID[row.uniqueID] = row
|
||||
}
|
||||
|
||||
lookup := make([]string, 0, len(byUID)*2)
|
||||
seenLookup := map[string]struct{}{}
|
||||
addLookup := func(uid string) {
|
||||
if uid == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := known[uid]; ok {
|
||||
return
|
||||
}
|
||||
if _, ok := seenLookup[uid]; ok {
|
||||
return
|
||||
}
|
||||
seenLookup[uid] = struct{}{}
|
||||
lookup = append(lookup, uid)
|
||||
}
|
||||
for _, row := range byUID {
|
||||
addLookup(row.uniqueID)
|
||||
if row.parent != nil {
|
||||
addLookup(*row.parent)
|
||||
}
|
||||
}
|
||||
if err := s.loadCategoryPaths(ctx, companyID, lookup, known); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updIDs := make([]uuid.UUID, 0, len(order))
|
||||
updNames := make([]string, 0, len(order))
|
||||
updDescs := make([]string, 0, len(order))
|
||||
pendingInserts := make([]categoryCSVRow, 0, len(order))
|
||||
|
||||
for _, uid := range order {
|
||||
row := byUID[uid]
|
||||
if info, ok := known[uid]; ok {
|
||||
updIDs = append(updIDs, info.id)
|
||||
updNames = append(updNames, row.name)
|
||||
updDescs = append(updDescs, deref(row.desc))
|
||||
continue
|
||||
}
|
||||
pendingInserts = append(pendingInserts, row)
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if len(updIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE categories AS c SET
|
||||
name = v.name,
|
||||
description = CASE WHEN v.description <> '' THEN v.description ELSE c.description END,
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, description)
|
||||
WHERE c.id = v.id AND c.company_id = $1`,
|
||||
companyID, updIDs, updNames, updDescs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updIDs)
|
||||
}
|
||||
|
||||
for len(pendingInserts) > 0 {
|
||||
insNames := make([]string, 0, len(pendingInserts))
|
||||
insUIDs := make([]string, 0, len(pendingInserts))
|
||||
insParents := make([]string, 0, len(pendingInserts))
|
||||
insDescs := make([]string, 0, len(pendingInserts))
|
||||
insPaths := make([]string, 0, len(pendingInserts))
|
||||
insLevels := make([]int32, 0, len(pendingInserts))
|
||||
next := make([]categoryCSVRow, 0, len(pendingInserts))
|
||||
|
||||
for _, row := range pendingInserts {
|
||||
path, level, parentVal, err := resolveCategoryPath(row.uniqueID, row.parent, known)
|
||||
if err != nil {
|
||||
next = append(next, row)
|
||||
continue
|
||||
}
|
||||
insNames = append(insNames, row.name)
|
||||
insUIDs = append(insUIDs, row.uniqueID)
|
||||
insParents = append(insParents, parentVal)
|
||||
insDescs = append(insDescs, deref(row.desc))
|
||||
insPaths = append(insPaths, path)
|
||||
insLevels = append(insLevels, int32(level))
|
||||
}
|
||||
|
||||
if len(insUIDs) == 0 {
|
||||
for _, row := range next {
|
||||
res.Skipped++
|
||||
res.addError(fmt.Sprintf("%s: parent category not found", row.uniqueID))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
rows, err := tx.Query(ctx, `
|
||||
INSERT INTO categories (company_id, name, unique_id, parent_unique_id, description, path, level)
|
||||
SELECT $1, v.name, v.unique_id, NULLIF(v.parent_unique_id, ''), NULLIF(v.description, ''), v.path, v.level
|
||||
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::int[])
|
||||
AS v(name, unique_id, parent_unique_id, description, path, level)
|
||||
RETURNING id, unique_id, COALESCE(path, ''), level`,
|
||||
companyID, insNames, insUIDs, insParents, insDescs, insPaths, insLevels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var uid, path string
|
||||
var level int
|
||||
if err := rows.Scan(&id, &uid, &path, &level); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
known[uid] = categoryPathInfo{id: id, path: path, level: level}
|
||||
res.Created++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingInserts = next
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) loadCategoryPaths(ctx context.Context, companyID uuid.UUID, uids []string, known map[string]categoryPathInfo) error {
|
||||
if len(uids) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, unique_id, COALESCE(path, ''), level
|
||||
FROM categories
|
||||
WHERE company_id = $1 AND unique_id = ANY($2)`, companyID, uids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var info categoryPathInfo
|
||||
var uid string
|
||||
if err := rows.Scan(&info.id, &uid, &info.path, &info.level); err != nil {
|
||||
return err
|
||||
}
|
||||
known[uid] = info
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func resolveCategoryPath(uniqueID string, parent *string, known map[string]categoryPathInfo) (path string, level int, parentVal string, err error) {
|
||||
path = uniqueID
|
||||
level = 0
|
||||
if parent == nil || strings.TrimSpace(*parent) == "" {
|
||||
return path, level, "", nil
|
||||
}
|
||||
p := strings.TrimSpace(*parent)
|
||||
pinfo, ok := known[p]
|
||||
if !ok {
|
||||
return "", 0, "", errors.New("parent category not found")
|
||||
}
|
||||
if pinfo.path != "" {
|
||||
path = pinfo.path + "/" + uniqueID
|
||||
} else {
|
||||
path = p + "/" + uniqueID
|
||||
}
|
||||
return path, pinfo.level + 1, p, nil
|
||||
}
|
||||
|
||||
func deref(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
type attributeCSVRow struct {
|
||||
key, name, valueType string
|
||||
unit, example, parent *string
|
||||
}
|
||||
|
||||
func (s *Service) ImportAttributesCSV(ctx context.Context, companyID uuid.UUID, r io.Reader) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iKey := headerIndex(headers, "attribute_key", "key")
|
||||
iName := headerIndex(headers, "name")
|
||||
iType := headerIndex(headers, "value_type", "type")
|
||||
iUnit := headerIndex(headers, "unit")
|
||||
iExample := headerIndex(headers, "example")
|
||||
iParent := headerIndex(headers, "parent_key", "parent")
|
||||
if iKey < 0 || iName < 0 {
|
||||
return res, ClientMsg("CSV must include attribute_key and name columns")
|
||||
}
|
||||
|
||||
batch := make([]attributeCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
key := cell(row, iKey)
|
||||
name := cell(row, iName)
|
||||
if key == "" || name == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
valueType := cell(row, iType)
|
||||
if valueType == "" {
|
||||
valueType = "string"
|
||||
}
|
||||
var unit, example, parent *string
|
||||
if u := cell(row, iUnit); u != "" {
|
||||
unit = &u
|
||||
}
|
||||
if e := cell(row, iExample); e != "" {
|
||||
example = &e
|
||||
}
|
||||
if p := cell(row, iParent); p != "" {
|
||||
parent = &p
|
||||
}
|
||||
batch = append(batch, attributeCSVRow{
|
||||
key: key, name: name, valueType: valueType,
|
||||
unit: unit, example: example, parent: parent,
|
||||
})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushAttributeBatch(ctx, companyID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushAttributeBatch(ctx context.Context, companyID uuid.UUID, batch []attributeCSVRow, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
byKey := make(map[string]attributeCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byKey[row.key]; !ok {
|
||||
order = append(order, row.key)
|
||||
}
|
||||
byKey[row.key] = row
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(byKey))
|
||||
keys = append(keys, order...)
|
||||
existing := map[string]uuid.UUID{}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, attribute_key FROM attributes
|
||||
WHERE company_id = $1 AND attribute_key = ANY($2)`, companyID, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var key string
|
||||
if err := rows.Scan(&id, &key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existing[key] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updIDs := make([]uuid.UUID, 0, len(order))
|
||||
updNames := make([]string, 0, len(order))
|
||||
updTypes := make([]string, 0, len(order))
|
||||
insKeys := make([]string, 0, len(order))
|
||||
insNames := make([]string, 0, len(order))
|
||||
insTypes := make([]string, 0, len(order))
|
||||
insUnits := make([]string, 0, len(order))
|
||||
insExamples := make([]string, 0, len(order))
|
||||
insParents := make([]string, 0, len(order))
|
||||
|
||||
for _, key := range order {
|
||||
row := byKey[key]
|
||||
if id, ok := existing[key]; ok {
|
||||
updIDs = append(updIDs, id)
|
||||
updNames = append(updNames, row.name)
|
||||
updTypes = append(updTypes, row.valueType)
|
||||
continue
|
||||
}
|
||||
insKeys = append(insKeys, key)
|
||||
insNames = append(insNames, row.name)
|
||||
insTypes = append(insTypes, row.valueType)
|
||||
insUnits = append(insUnits, deref(row.unit))
|
||||
insExamples = append(insExamples, deref(row.example))
|
||||
insParents = append(insParents, deref(row.parent))
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if len(updIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE attributes AS a SET
|
||||
name = CASE WHEN v.name <> '' THEN v.name ELSE a.name END,
|
||||
value_type = CASE WHEN v.value_type <> '' THEN v.value_type ELSE a.value_type END,
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[]) AS v(id, name, value_type)
|
||||
WHERE a.id = v.id AND a.company_id = $1`,
|
||||
companyID, updIDs, updNames, updTypes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updIDs)
|
||||
}
|
||||
|
||||
if len(insKeys) > 0 {
|
||||
ct, err := tx.Exec(ctx, `
|
||||
INSERT INTO attributes (company_id, attribute_key, name, value_type, unit, example, parent_key)
|
||||
SELECT $1, v.attribute_key, v.name, v.value_type,
|
||||
NULLIF(v.unit, ''), NULLIF(v.example, ''), NULLIF(v.parent_key, '')
|
||||
FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(attribute_key, name, value_type, unit, example, parent_key)`,
|
||||
companyID, insKeys, insNames, insTypes, insUnits, insExamples, insParents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Created += int(ct.RowsAffected())
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProductsByGTIN(ctx context.Context, companyID uuid.UUID) (bool, error) {
|
||||
var merge bool
|
||||
err := s.Pool.QueryRow(ctx, `SELECT merge_products_by_gtin FROM companies WHERE id = $1`, companyID).Scan(&merge)
|
||||
return merge, err
|
||||
}
|
||||
|
||||
type productCSVRow struct {
|
||||
gtin, productID, name, category, desc, status string
|
||||
rawJSON string
|
||||
mergeable bool
|
||||
}
|
||||
|
||||
func (s *Service) ImportProductsCSV(ctx context.Context, companyID uuid.UUID, r io.Reader, fileID *uuid.UUID) (ImportResult, error) {
|
||||
res := ImportResult{}
|
||||
merge, err := s.MergeProductsByGTIN(ctx, companyID)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
cr := csv.NewReader(r)
|
||||
cr.TrimLeadingSpace = true
|
||||
headers, err := cr.Read()
|
||||
if err != nil {
|
||||
return res, ClientMsg("empty or invalid CSV")
|
||||
}
|
||||
iGTIN := headerIndex(headers, "gtin", "ean", "barcode")
|
||||
iPID := headerIndex(headers, "product_id", "sku", "id")
|
||||
iName := headerIndex(headers, "name", "title")
|
||||
iCat := headerIndex(headers, "category")
|
||||
iDesc := headerIndex(headers, "description")
|
||||
iStatus := headerIndex(headers, "status")
|
||||
if iName < 0 && iGTIN < 0 && iPID < 0 {
|
||||
return res, ClientMsg("CSV must include name, gtin, or product_id")
|
||||
}
|
||||
|
||||
batch := make([]productCSVRow, 0, importCSVBatchSize)
|
||||
for {
|
||||
row, err := cr.Read()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
res.Skipped++
|
||||
res.addError(err.Error())
|
||||
continue
|
||||
}
|
||||
gtin := cell(row, iGTIN)
|
||||
productID := cell(row, iPID)
|
||||
name := cell(row, iName)
|
||||
category := cell(row, iCat)
|
||||
desc := cell(row, iDesc)
|
||||
status := cell(row, iStatus)
|
||||
if status == "" {
|
||||
status = "draft"
|
||||
}
|
||||
if gtin == "" && productID == "" && name == "" {
|
||||
res.Skipped++
|
||||
continue
|
||||
}
|
||||
if gtin == "" {
|
||||
gtin = "nogtin-" + uuid.NewString()
|
||||
}
|
||||
rawData, _ := json.Marshal(map[string]any{
|
||||
"product_id": productID,
|
||||
"name": name,
|
||||
"category": category,
|
||||
"description": desc,
|
||||
"status": status,
|
||||
"gtin": gtin,
|
||||
})
|
||||
batch = append(batch, productCSVRow{
|
||||
gtin: gtin, productID: productID, name: name, category: category,
|
||||
desc: desc, status: status, rawJSON: string(rawData),
|
||||
mergeable: merge && !strings.HasPrefix(gtin, "nogtin-"),
|
||||
})
|
||||
if len(batch) >= importCSVBatchSize {
|
||||
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
if err := s.flushProductBatch(ctx, companyID, fileID, batch, &res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) flushProductBatch(ctx context.Context, companyID uuid.UUID, fileID *uuid.UUID, batch []productCSVRow, res *ImportResult) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Last row wins per GTIN so a single INSERT cannot hit the same unique key twice.
|
||||
byGTIN := make(map[string]productCSVRow, len(batch))
|
||||
order := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if _, ok := byGTIN[row.gtin]; !ok {
|
||||
order = append(order, row.gtin)
|
||||
}
|
||||
byGTIN[row.gtin] = row
|
||||
}
|
||||
deduped := make([]productCSVRow, 0, len(order))
|
||||
for _, gtin := range order {
|
||||
deduped = append(deduped, byGTIN[gtin])
|
||||
}
|
||||
batch = deduped
|
||||
|
||||
mergeGTINs := make([]string, 0, len(batch))
|
||||
for _, row := range batch {
|
||||
if row.mergeable {
|
||||
mergeGTINs = append(mergeGTINs, row.gtin)
|
||||
}
|
||||
}
|
||||
|
||||
existingRaw := map[string]uuid.UUID{}
|
||||
if len(mergeGTINs) > 0 {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (gtin) id, gtin
|
||||
FROM raw_products
|
||||
WHERE company_id = $1 AND gtin = ANY($2)
|
||||
ORDER BY gtin, updated_at DESC`, companyID, mergeGTINs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var gtin string
|
||||
if err := rows.Scan(&id, >in); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
existingRaw[gtin] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type pendingProcessed struct {
|
||||
rawID uuid.UUID
|
||||
productID, name, category, desc, status string
|
||||
rawWasUpdate bool
|
||||
}
|
||||
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
updRawIDs := make([]uuid.UUID, 0, len(batch))
|
||||
updRawJSON := make([]string, 0, len(batch))
|
||||
updRawMeta := make([]pendingProcessed, 0, len(batch))
|
||||
|
||||
insGTINs := make([]string, 0, len(batch))
|
||||
insJSON := make([]string, 0, len(batch))
|
||||
insMeta := make([]pendingProcessed, 0, len(batch))
|
||||
|
||||
for _, row := range batch {
|
||||
meta := pendingProcessed{
|
||||
productID: row.productID, name: row.name, category: row.category,
|
||||
desc: row.desc, status: row.status,
|
||||
}
|
||||
if row.mergeable {
|
||||
if id, ok := existingRaw[row.gtin]; ok {
|
||||
updRawIDs = append(updRawIDs, id)
|
||||
updRawJSON = append(updRawJSON, row.rawJSON)
|
||||
meta.rawID = id
|
||||
meta.rawWasUpdate = true
|
||||
updRawMeta = append(updRawMeta, meta)
|
||||
continue
|
||||
}
|
||||
}
|
||||
insGTINs = append(insGTINs, row.gtin)
|
||||
insJSON = append(insJSON, row.rawJSON)
|
||||
insMeta = append(insMeta, meta)
|
||||
}
|
||||
|
||||
if len(updRawIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE raw_products AS r SET
|
||||
raw_data = v.raw_data::jsonb,
|
||||
mapped_data = v.raw_data::jsonb,
|
||||
file_id = COALESCE($3, r.file_id),
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $4::text[]) AS v(id, raw_data)
|
||||
WHERE r.id = v.id AND r.company_id = $1`,
|
||||
companyID, updRawIDs, fileID, updRawJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
pending := make([]pendingProcessed, 0, len(batch))
|
||||
pending = append(pending, updRawMeta...)
|
||||
|
||||
if len(insGTINs) > 0 {
|
||||
rows, err := tx.Query(ctx, `
|
||||
INSERT INTO raw_products (company_id, gtin, raw_data, mapped_data, processing_status, file_id)
|
||||
SELECT $1, v.gtin, v.raw_data::jsonb, v.raw_data::jsonb, 'unprocessed', $2
|
||||
FROM unnest($3::text[], $4::text[]) AS v(gtin, raw_data)
|
||||
ON CONFLICT (company_id, gtin) DO NOTHING
|
||||
RETURNING id, gtin`,
|
||||
companyID, fileID, insGTINs, insJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
insertedByGTIN := map[string]uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var gtin string
|
||||
if err := rows.Scan(&id, >in); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
insertedByGTIN[gtin] = id
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Match inserts back to input order; conflicts (DO NOTHING) are skipped.
|
||||
for i, gtin := range insGTINs {
|
||||
id, ok := insertedByGTIN[gtin]
|
||||
if !ok {
|
||||
res.Skipped++
|
||||
res.addError(fmt.Sprintf("%s: duplicate gtin", gtin))
|
||||
continue
|
||||
}
|
||||
meta := insMeta[i]
|
||||
meta.rawID = id
|
||||
pending = append(pending, meta)
|
||||
// Same gtin inserted twice in one batch: second RETURNING miss.
|
||||
delete(insertedByGTIN, gtin)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
rawIDs := make([]uuid.UUID, len(pending))
|
||||
for i, p := range pending {
|
||||
rawIDs[i] = p.rawID
|
||||
}
|
||||
existingProcessed := map[uuid.UUID]uuid.UUID{}
|
||||
prows, err := tx.Query(ctx, `
|
||||
SELECT DISTINCT ON (raw_product_id) id, raw_product_id
|
||||
FROM processed_products
|
||||
WHERE company_id = $1 AND raw_product_id = ANY($2)
|
||||
ORDER BY raw_product_id, updated_at DESC`, companyID, rawIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for prows.Next() {
|
||||
var id, rawID uuid.UUID
|
||||
if err := prows.Scan(&id, &rawID); err != nil {
|
||||
prows.Close()
|
||||
return err
|
||||
}
|
||||
existingProcessed[rawID] = id
|
||||
}
|
||||
err = prows.Err()
|
||||
prows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updProcIDs := make([]uuid.UUID, 0, len(pending))
|
||||
updPIDs := make([]string, 0, len(pending))
|
||||
updNames := make([]string, 0, len(pending))
|
||||
updCats := make([]string, 0, len(pending))
|
||||
updDescs := make([]string, 0, len(pending))
|
||||
updStatuses := make([]string, 0, len(pending))
|
||||
|
||||
insRawIDs := make([]uuid.UUID, 0, len(pending))
|
||||
insPIDs := make([]string, 0, len(pending))
|
||||
insNames := make([]string, 0, len(pending))
|
||||
insCats := make([]string, 0, len(pending))
|
||||
insDescs := make([]string, 0, len(pending))
|
||||
insStatuses := make([]string, 0, len(pending))
|
||||
insWasUpdate := make([]bool, 0, len(pending))
|
||||
|
||||
for _, p := range pending {
|
||||
if pid, ok := existingProcessed[p.rawID]; ok {
|
||||
updProcIDs = append(updProcIDs, pid)
|
||||
updPIDs = append(updPIDs, p.productID)
|
||||
updNames = append(updNames, p.name)
|
||||
updCats = append(updCats, p.category)
|
||||
updDescs = append(updDescs, p.desc)
|
||||
updStatuses = append(updStatuses, p.status)
|
||||
continue
|
||||
}
|
||||
insRawIDs = append(insRawIDs, p.rawID)
|
||||
insPIDs = append(insPIDs, p.productID)
|
||||
insNames = append(insNames, p.name)
|
||||
insCats = append(insCats, p.category)
|
||||
insDescs = append(insDescs, p.desc)
|
||||
insStatuses = append(insStatuses, p.status)
|
||||
insWasUpdate = append(insWasUpdate, p.rawWasUpdate)
|
||||
}
|
||||
|
||||
if len(updProcIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE processed_products AS p SET
|
||||
product_id = COALESCE(NULLIF(v.product_id, ''), p.product_id),
|
||||
name = COALESCE(NULLIF(v.name, ''), p.name),
|
||||
category = COALESCE(NULLIF(v.category, ''), p.category),
|
||||
description = COALESCE(NULLIF(v.description, ''), p.description),
|
||||
status = COALESCE(NULLIF(v.status, ''), p.status),
|
||||
updated_at = now()
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(id, product_id, name, category, description, status)
|
||||
WHERE p.id = v.id AND p.company_id = $1`,
|
||||
companyID, updProcIDs, updPIDs, updNames, updCats, updDescs, updStatuses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Updated += len(updProcIDs)
|
||||
}
|
||||
|
||||
if len(insRawIDs) > 0 {
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO processed_products (company_id, product_id, name, category, description, status, raw_product_id)
|
||||
SELECT $1,
|
||||
NULLIF(v.product_id, ''), NULLIF(v.name, ''), NULLIF(v.category, ''),
|
||||
NULLIF(v.description, ''), v.status, v.raw_product_id
|
||||
FROM unnest($2::uuid[], $3::text[], $4::text[], $5::text[], $6::text[], $7::text[])
|
||||
AS v(raw_product_id, product_id, name, category, description, status)`,
|
||||
companyID, insRawIDs, insPIDs, insNames, insCats, insDescs, insStatuses)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, wasUpdate := range insWasUpdate {
|
||||
if wasUpdate {
|
||||
res.Updated++
|
||||
} else {
|
||||
res.Created++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestHeaderIndexAndCell(t *testing.T) {
|
||||
headers := []string{" Name ", "GTIN", "sku"}
|
||||
if got := headerIndex(headers, "name"); got != 0 {
|
||||
t.Fatalf("headerIndex name: got %d want 0", got)
|
||||
}
|
||||
if got := headerIndex(headers, "ean", "gtin"); got != 1 {
|
||||
t.Fatalf("headerIndex gtin: got %d want 1", got)
|
||||
}
|
||||
if got := headerIndex(headers, "missing"); got != -1 {
|
||||
t.Fatalf("headerIndex missing: got %d want -1", got)
|
||||
}
|
||||
row := []string{" a ", "b"}
|
||||
if got := cell(row, 0); got != "a" {
|
||||
t.Fatalf("cell: got %q want a", got)
|
||||
}
|
||||
if got := cell(row, 5); got != "" {
|
||||
t.Fatalf("cell OOB: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportResultAddErrorCaps(t *testing.T) {
|
||||
res := ImportResult{}
|
||||
for i := 0; i < importCSVMaxErrors+20; i++ {
|
||||
res.addError("err")
|
||||
}
|
||||
if len(res.Errors) != importCSVMaxErrors {
|
||||
t.Fatalf("errors capped: got %d want %d", len(res.Errors), importCSVMaxErrors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCategoryPath(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
known := map[string]categoryPathInfo{
|
||||
"parent": {id: parentID, path: "root/parent", level: 1},
|
||||
}
|
||||
path, level, parentVal, err := resolveCategoryPath("child", strPtr("parent"), known)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if path != "root/parent/child" || level != 2 || parentVal != "parent" {
|
||||
t.Fatalf("got path=%q level=%d parent=%q", path, level, parentVal)
|
||||
}
|
||||
_, _, _, err = resolveCategoryPath("child", strPtr("missing"), known)
|
||||
if err == nil || err.Error() != "parent category not found" {
|
||||
t.Fatalf("got %v, want parent category not found", err)
|
||||
}
|
||||
path, level, parentVal, err = resolveCategoryPath("root", nil, known)
|
||||
if err != nil || path != "root" || level != 0 || parentVal != "" {
|
||||
t.Fatalf("root: path=%q level=%d parent=%q err=%v", path, level, parentVal, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCategoriesAndAttributesCSVBatch(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "csv-import-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
prefix := companyID.String()[:8]
|
||||
|
||||
catCSV := strings.NewReader("name,unique_id,parent_unique_id,description\n" +
|
||||
"Root,root-" + prefix + ",,root desc\n" +
|
||||
"Child,child-" + prefix + ",root-" + prefix + ",child desc\n")
|
||||
catRes, err := svc.ImportCategoriesCSV(ctx, companyID, catCSV)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportCategoriesCSV: %v", err)
|
||||
}
|
||||
if catRes.Created != 2 || catRes.Updated != 0 {
|
||||
t.Fatalf("categories create: %+v want created=2 updated=0", catRes)
|
||||
}
|
||||
|
||||
catUpdate := strings.NewReader("name,unique_id,description\n" +
|
||||
"Root,root-" + prefix + ",root updated\n")
|
||||
catRes2, err := svc.ImportCategoriesCSV(ctx, companyID, catUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportCategoriesCSV update: %v", err)
|
||||
}
|
||||
if catRes2.Created != 0 || catRes2.Updated != 1 {
|
||||
t.Fatalf("categories update: %+v want created=0 updated=1", catRes2)
|
||||
}
|
||||
|
||||
var rootName, rootDesc, childPath string
|
||||
var childLevel int
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT name, COALESCE(description, '') FROM categories
|
||||
WHERE company_id = $1 AND unique_id = $2`, companyID, "root-"+prefix).
|
||||
Scan(&rootName, &rootDesc)
|
||||
if err != nil {
|
||||
t.Fatalf("select root: %v", err)
|
||||
}
|
||||
if rootName != "Root" || rootDesc != "root updated" {
|
||||
t.Fatalf("root fields: name=%q desc=%q", rootName, rootDesc)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT COALESCE(path, ''), level FROM categories
|
||||
WHERE company_id = $1 AND unique_id = $2`, companyID, "child-"+prefix).
|
||||
Scan(&childPath, &childLevel)
|
||||
if err != nil {
|
||||
t.Fatalf("select child: %v", err)
|
||||
}
|
||||
wantPath := "root-" + prefix + "/child-" + prefix
|
||||
if childPath != wantPath || childLevel != 1 {
|
||||
t.Fatalf("child path=%q level=%d want %q / 1", childPath, childLevel, wantPath)
|
||||
}
|
||||
|
||||
attrCSV := strings.NewReader("attribute_key,name,value_type,unit\n" +
|
||||
"color,Color,string,\n" +
|
||||
"size,Size,string,cm\n")
|
||||
attrRes, err := svc.ImportAttributesCSV(ctx, companyID, attrCSV)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAttributesCSV: %v", err)
|
||||
}
|
||||
if attrRes.Created != 2 || attrRes.Updated != 0 {
|
||||
t.Fatalf("attributes create: %+v want created=2 updated=0", attrRes)
|
||||
}
|
||||
attrUpdate := strings.NewReader("attribute_key,name,value_type\n" +
|
||||
"color,Colour,string\n")
|
||||
attrRes2, err := svc.ImportAttributesCSV(ctx, companyID, attrUpdate)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportAttributesCSV update: %v", err)
|
||||
}
|
||||
if attrRes2.Created != 0 || attrRes2.Updated != 1 {
|
||||
t.Fatalf("attributes update: %+v want created=0 updated=1", attrRes2)
|
||||
}
|
||||
var colorName string
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT name FROM attributes WHERE company_id = $1 AND attribute_key = 'color'`, companyID).
|
||||
Scan(&colorName)
|
||||
if err != nil {
|
||||
t.Fatalf("select color: %v", err)
|
||||
}
|
||||
if colorName != "Colour" {
|
||||
t.Fatalf("color name=%q want Colour", colorName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportProductsCSVBatchMerge(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `
|
||||
INSERT INTO companies (id, name, merge_products_by_gtin) VALUES ($1, $2, true)`,
|
||||
companyID, "csv-products-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
gtin := "590123412345" + companyID.String()[:3]
|
||||
csv1 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",First,SKU-1,draft\n")
|
||||
res1, err := svc.ImportProductsCSV(ctx, companyID, csv1, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProductsCSV create: %v", err)
|
||||
}
|
||||
if res1.Created != 1 || res1.Updated != 0 {
|
||||
t.Fatalf("create result: %+v", res1)
|
||||
}
|
||||
|
||||
csv2 := strings.NewReader("gtin,name,product_id,status\n" + gtin + ",Second,SKU-2,published\n")
|
||||
res2, err := svc.ImportProductsCSV(ctx, companyID, csv2, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportProductsCSV update: %v", err)
|
||||
}
|
||||
if res2.Updated != 1 {
|
||||
t.Fatalf("update result: %+v want updated=1", res2)
|
||||
}
|
||||
|
||||
var rawCount int
|
||||
var name string
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT count(*) FROM raw_products WHERE company_id = $1 AND gtin = $2`, companyID, gtin).
|
||||
Scan(&rawCount)
|
||||
if err != nil {
|
||||
t.Fatalf("count raw: %v", err)
|
||||
}
|
||||
if rawCount != 1 {
|
||||
t.Fatalf("raw_products count=%d want 1", rawCount)
|
||||
}
|
||||
err = pg.QueryRow(ctx, `
|
||||
SELECT COALESCE(p.name, '') FROM processed_products p
|
||||
JOIN raw_products r ON r.id = p.raw_product_id
|
||||
WHERE p.company_id = $1 AND r.gtin = $2`, companyID, gtin).Scan(&name)
|
||||
if err != nil {
|
||||
t.Fatalf("select processed: %v", err)
|
||||
}
|
||||
if name != "Second" {
|
||||
t.Fatalf("processed name=%q want Second", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportCategoriesCSVRejectsMissingColumns(t *testing.T) {
|
||||
svc := &Service{}
|
||||
_, err := svc.ImportCategoriesCSV(context.Background(), uuid.New(), strings.NewReader("foo,bar\n1,2\n"))
|
||||
if err == nil || err.Error() != "CSV must include name and unique_id columns" {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,159 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/feeds"
|
||||
)
|
||||
|
||||
// linkFeedSpecificationsIntoProduct expands mapped_data.specifications HTML/flat
|
||||
// blobs into attribute_key→value maps and merges them into attributes when empty.
|
||||
// Mutates item in place for GET product responses (does not persist).
|
||||
func linkFeedSpecificationsIntoProduct(item map[string]any) {
|
||||
if item == nil {
|
||||
return
|
||||
}
|
||||
mapped, _ := item["mapped_data"].(map[string]any)
|
||||
if mapped == nil {
|
||||
return
|
||||
}
|
||||
linked := extractLinkedSpecs(mapped)
|
||||
if len(linked) == 0 {
|
||||
return
|
||||
}
|
||||
// Prefer structured object in response mapped_data for the Attributes/Feed UI.
|
||||
if specs := mapped["specifications"]; isLooseSpecBlob(specs) {
|
||||
mapped["specifications"] = linked
|
||||
item["mapped_data"] = mapped
|
||||
} else if specs := mapped["specs"]; isLooseSpecBlob(specs) {
|
||||
mapped["specs"] = linked
|
||||
item["mapped_data"] = mapped
|
||||
}
|
||||
attrs := asStringAnyMap(item["attributes"])
|
||||
if len(attrs) == 0 {
|
||||
item["attributes"] = linked
|
||||
item["has_attributes"] = true
|
||||
return
|
||||
}
|
||||
merged := make(map[string]any, len(attrs)+len(linked))
|
||||
for k, v := range attrs {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range linked {
|
||||
if existing, ok := merged[k]; ok && strings.TrimSpace(stringifyAny(existing)) != "" {
|
||||
continue
|
||||
}
|
||||
merged[k] = v
|
||||
}
|
||||
item["attributes"] = merged
|
||||
item["has_attributes"] = true
|
||||
}
|
||||
|
||||
func extractLinkedSpecs(mapped map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
put := func(label, value string) {
|
||||
k := feeds.CanonicalAttributeKey(label)
|
||||
if k == "" || strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := out[k]; exists {
|
||||
return
|
||||
}
|
||||
out[k] = value
|
||||
}
|
||||
for _, key := range []string{"specifications", "specs"} {
|
||||
v, ok := mapped[key]
|
||||
if !ok || v == nil {
|
||||
continue
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
for _, p := range feeds.ParseSpecifications(t) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, val := range t {
|
||||
if strings.EqualFold(k, "_raw") {
|
||||
if s, ok := val.(string); ok {
|
||||
for _, p := range feeds.ParseSpecifications(s) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
put(k, stringifyAny(val))
|
||||
}
|
||||
case map[string]string:
|
||||
for k, val := range t {
|
||||
if strings.EqualFold(k, "_raw") {
|
||||
for _, p := range feeds.ParseSpecifications(val) {
|
||||
put(p.Label, p.Value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
put(k, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Scalar mapped fields → standard attribute keys (net_height, eprel_id, …).
|
||||
for _, src := range []string{
|
||||
"warranty", "eprel_id", "eprel",
|
||||
"netwidth", "net_width", "netheight", "net_height",
|
||||
"netdepth", "net_depth", "netmass", "net_mass",
|
||||
"productmodel", "product_model",
|
||||
"visina", "sirina", "globina", "teza",
|
||||
} {
|
||||
if s := stringifyAny(mapped[src]); s != "" {
|
||||
put(src, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isLooseSpecBlob(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t) != ""
|
||||
case map[string]any:
|
||||
_, hasRaw := t["_raw"]
|
||||
return hasRaw
|
||||
case map[string]string:
|
||||
_, hasRaw := t["_raw"]
|
||||
return hasRaw
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func asStringAnyMap(v any) map[string]any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
return t
|
||||
case map[string]string:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
out[k] = val
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringifyAny(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(t)
|
||||
case float64, float32, int, int64, bool:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package catalog
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLinkFeedSpecificationsIntoProductFillsAttributes(t *testing.T) {
|
||||
item := map[string]any{
|
||||
"mapped_data": map[string]any{
|
||||
"description": "Feed original description",
|
||||
"category": "46",
|
||||
"specifications": "Barva: črna; Garancija: 24",
|
||||
"warranty": "24",
|
||||
"net_width": "10",
|
||||
},
|
||||
"attributes": map[string]any{},
|
||||
}
|
||||
linkFeedSpecificationsIntoProduct(item)
|
||||
attrs, _ := item["attributes"].(map[string]any)
|
||||
if len(attrs) == 0 {
|
||||
t.Fatal("expected attributes filled from mapped feed specs")
|
||||
}
|
||||
if item["has_attributes"] != true {
|
||||
t.Fatalf("has_attributes=%v want true", item["has_attributes"])
|
||||
}
|
||||
mapped, _ := item["mapped_data"].(map[string]any)
|
||||
if mapped["description"] != "Feed original description" {
|
||||
t.Fatalf("description stripped from mapped_data")
|
||||
}
|
||||
if mapped["category"] != "46" {
|
||||
t.Fatalf("category stripped from mapped_data")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (s *Service) ListCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string) ([]map[string]any, error) {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return nil, ClientMsg("category_unique_id required")
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
|
||||
a.attribute_key, a.name, a.value_type
|
||||
FROM category_attributes ca
|
||||
INNER JOIN attributes a ON a.id = ca.attribute_id AND a.company_id = ca.company_id
|
||||
WHERE ca.company_id = $1 AND ca.category_unique_id = $2
|
||||
ORDER BY a.name`, companyID, categoryUniqueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanMaps(rows, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
|
||||
}
|
||||
|
||||
func (s *Service) LinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID, required bool) (map[string]any, error) {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return nil, ClientMsg("category_unique_id required")
|
||||
}
|
||||
var catExists bool
|
||||
if err := s.Pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
|
||||
companyID, categoryUniqueID).Scan(&catExists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !catExists {
|
||||
return nil, ClientMsg("category not found")
|
||||
}
|
||||
var attrCompany uuid.UUID
|
||||
err := s.Pool.QueryRow(ctx, `SELECT company_id FROM attributes WHERE id = $1`, attributeID).Scan(&attrCompany)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ClientMsg("attribute not found")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if attrCompany != companyID {
|
||||
return nil, ClientMsg("attribute not found")
|
||||
}
|
||||
var id uuid.UUID
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (company_id, category_unique_id, attribute_id)
|
||||
DO UPDATE SET required = EXCLUDED.required, updated_at = now()
|
||||
RETURNING id`, companyID, categoryUniqueID, attributeID, required).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required,
|
||||
a.attribute_key, a.name, a.value_type
|
||||
FROM category_attributes ca
|
||||
INNER JOIN attributes a ON a.id = ca.attribute_id
|
||||
WHERE ca.id = $1 AND ca.company_id = $2`, id, companyID)
|
||||
return scanMap(row, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"})
|
||||
}
|
||||
|
||||
func (s *Service) UnlinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID) error {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
ct, err := s.Pool.Exec(ctx, `
|
||||
DELETE FROM category_attributes
|
||||
WHERE company_id = $1 AND category_unique_id = $2 AND attribute_id = $3`,
|
||||
companyID, categoryUniqueID, attributeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ReplaceCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeIDs []uuid.UUID, required map[string]bool) error {
|
||||
categoryUniqueID = strings.TrimSpace(categoryUniqueID)
|
||||
if categoryUniqueID == "" {
|
||||
return ClientMsg("category_unique_id required")
|
||||
}
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var catExists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`,
|
||||
companyID, categoryUniqueID).Scan(&catExists); err != nil {
|
||||
return err
|
||||
}
|
||||
if !catExists {
|
||||
return ClientMsg("category not found")
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM category_attributes WHERE company_id = $1 AND category_unique_id = $2`,
|
||||
companyID, categoryUniqueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(attributeIDs) == 0 {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
if required == nil {
|
||||
required = map[string]bool{}
|
||||
}
|
||||
|
||||
ownedRows, err := tx.Query(ctx, `
|
||||
SELECT id FROM attributes WHERE company_id = $1 AND id = ANY($2::uuid[])`,
|
||||
companyID, attributeIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned := make([]uuid.UUID, 0, len(attributeIDs))
|
||||
for ownedRows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := ownedRows.Scan(&id); err != nil {
|
||||
ownedRows.Close()
|
||||
return err
|
||||
}
|
||||
owned = append(owned, id)
|
||||
}
|
||||
err = ownedRows.Err()
|
||||
ownedRows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAttributeIDsOwned(attributeIDs, owned); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reqs := make([]bool, len(attributeIDs))
|
||||
for i, aid := range attributeIDs {
|
||||
reqs[i] = required[aid.String()]
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required)
|
||||
SELECT $1, $2, u.attribute_id, u.required
|
||||
FROM unnest($3::uuid[], $4::boolean[]) AS u(attribute_id, required)`,
|
||||
companyID, categoryUniqueID, attributeIDs, reqs); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// validateAttributeIDsOwned ensures every requested attribute ID is present in the
|
||||
// company-scoped ownership query result. Missing or cross-tenant IDs surface as
|
||||
// "attribute not found" (same message as the former per-row SELECT path).
|
||||
func validateAttributeIDsOwned(attributeIDs, owned []uuid.UUID) error {
|
||||
if len(attributeIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[uuid.UUID]struct{}, len(owned))
|
||||
for _, id := range owned {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, aid := range attributeIDs {
|
||||
if _, ok := set[aid]; !ok {
|
||||
return ClientMsg("attribute not found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestValidateAttributeIDsOwned(t *testing.T) {
|
||||
a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
|
||||
c := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
|
||||
t.Run("empty request", func(t *testing.T) {
|
||||
if err := validateAttributeIDsOwned(nil, nil); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all owned", func(t *testing.T) {
|
||||
if err := validateAttributeIDsOwned([]uuid.UUID{a, b}, []uuid.UUID{b, a}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate request ids still ok when owned", func(t *testing.T) {
|
||||
// Ownership query returns distinct rows; duplicates are allowed through to INSERT
|
||||
// (unique constraint rejects them later — same as the old per-row path).
|
||||
if err := validateAttributeIDsOwned([]uuid.UUID{a, a}, []uuid.UUID{a}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing id", func(t *testing.T) {
|
||||
err := validateAttributeIDsOwned([]uuid.UUID{a, c}, []uuid.UUID{a})
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-tenant treated as missing", func(t *testing.T) {
|
||||
err := validateAttributeIDsOwned([]uuid.UUID{b}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func asUUID(t *testing.T, v any) uuid.UUID {
|
||||
t.Helper()
|
||||
switch x := v.(type) {
|
||||
case uuid.UUID:
|
||||
return x
|
||||
case string:
|
||||
id, err := uuid.Parse(x)
|
||||
if err != nil {
|
||||
t.Fatalf("parse uuid %q: %v", x, err)
|
||||
}
|
||||
return id
|
||||
case [16]byte:
|
||||
return uuid.UUID(x)
|
||||
default:
|
||||
t.Fatalf("unexpected uuid type %T", v)
|
||||
return uuid.Nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCategoryAttributesBatch(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres: %v", err)
|
||||
}
|
||||
defer pg.Close()
|
||||
|
||||
companyID := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
companyID, "links-test-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
|
||||
svc := &Service{Pool: pg}
|
||||
catUnique := "links-cat-" + companyID.String()[:8]
|
||||
if _, err := svc.CreateCategory(ctx, companyID, "Links Test Cat", catUnique, nil, nil); err != nil {
|
||||
t.Fatalf("CreateCategory: %v", err)
|
||||
}
|
||||
attrA, err := svc.CreateAttribute(ctx, companyID, "color", "Color", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute A: %v", err)
|
||||
}
|
||||
attrB, err := svc.CreateAttribute(ctx, companyID, "size", "Size", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute B: %v", err)
|
||||
}
|
||||
idA := asUUID(t, attrA["id"])
|
||||
idB := asUUID(t, attrB["id"])
|
||||
|
||||
otherCompany := uuid.New()
|
||||
_, err = pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`,
|
||||
otherCompany, "links-other-"+otherCompany.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatalf("insert other company: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pg.Exec(context.Background(), `DELETE FROM companies WHERE id = $1`, otherCompany)
|
||||
})
|
||||
foreign, err := svc.CreateAttribute(ctx, otherCompany, "foreign", "Foreign", "string", nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAttribute foreign: %v", err)
|
||||
}
|
||||
foreignID := asUUID(t, foreign["id"])
|
||||
|
||||
t.Run("batch replace with required flags", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idA, idB}, map[string]bool{
|
||||
idA.String(): true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("want 2 links, got %d", len(items))
|
||||
}
|
||||
byAttr := map[uuid.UUID]bool{}
|
||||
for _, item := range items {
|
||||
aid := asUUID(t, item["attribute_id"])
|
||||
req, _ := item["required"].(bool)
|
||||
byAttr[aid] = req
|
||||
}
|
||||
if !byAttr[idA] {
|
||||
t.Fatal("attribute A should be required")
|
||||
}
|
||||
if byAttr[idB] {
|
||||
t.Fatal("attribute B should not be required")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replace clears previous links", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{idB}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("want 1 link, got %d", len(items))
|
||||
}
|
||||
if asUUID(t, items[0]["attribute_id"]) != idB {
|
||||
t.Fatalf("want attribute B, got %v", items[0]["attribute_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty list clears all", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ReplaceCategoryAttributes: %v", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("want 0 links, got %d", len(items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing attribute", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{uuid.New()}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cross-tenant attribute", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, catUnique, []uuid.UUID{foreignID}, nil)
|
||||
if err == nil || err.Error() != "attribute not found" {
|
||||
t.Fatalf("got %v, want attribute not found", err)
|
||||
}
|
||||
items, err := svc.ListCategoryAttributes(ctx, companyID, catUnique)
|
||||
if err != nil {
|
||||
t.Fatalf("ListCategoryAttributes: %v", err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("failed replace must leave links empty, got %d", len(items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("category not found", func(t *testing.T) {
|
||||
err := svc.ReplaceCategoryAttributes(ctx, companyID, "no-such-category", []uuid.UUID{idA}, nil)
|
||||
if err == nil || err.Error() != "category not found" {
|
||||
t.Fatalf("got %v, want category not found", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user