package server import ( "context" "net/http" "time" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // catalog is hard-coded for now. PRODUCT_INTEGRATION_SPEC.md §10 has products // publish a manifest to `cdn.breakpilot.com`; this list will be sourced // from those manifests once M6.3 / M7.2 wire it up. var catalog = []store.CatalogEntry{ { Key: "certifai", Name: "CERTifAI", Description: "Self-hosted GDPR-compliant AI dashboard.", PlansRequired: []string{"professional", "enterprise"}, SupportsTrial: true, }, { Key: "compliance", Name: "Compliance", Description: "DSFA / TOM / VVT generation; evidence capture.", PlansRequired: []string{"starter", "professional", "enterprise"}, SupportsTrial: true, }, } func (s *Server) getCatalog(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"items": catalog}) } type catalogRequestReq struct { TenantID string `json:"tenant_id"` Product string `json:"product"` } // catalogRequest — customer requests a non-subscribed product. Today this // just emits an audit event tagged so the eventual ERPNext-Lead step // (M11.1) can pick it up. func (s *Server) catalogRequest(w http.ResponseWriter, r *http.Request) { var in catalogRequestReq if !decodeJSON(w, r, &in) { return } if in.TenantID == "" || in.Product == "" { writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required") return } if !isKnownProduct(in.Product) { writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } s.emitAudit(ctx, r, store.AuditEvent{ TenantID: in.TenantID, Action: "catalog.requested", TargetID: in.Product, TargetType: "product", Metadata: map[string]interface{}{"product": in.Product}, }) writeJSON(w, http.StatusAccepted, map[string]string{ "status": "accepted", "message": "request recorded; sales will be in touch", }) } // catalogTrialRequest — customer self-serves a 14-day trial of a product // that supports trial. Provisions the entitlement immediately so the // product can be used right away. func (s *Server) catalogTrialRequest(w http.ResponseWriter, r *http.Request) { var in catalogRequestReq if !decodeJSON(w, r, &in) { return } if in.TenantID == "" || in.Product == "" { writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and product are required") return } entry, ok := lookupCatalogEntry(in.Product) if !ok { writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") return } if !entry.SupportsTrial { writeError(w, http.StatusBadRequest, "trial_unavailable", "product does not support self-serve trial") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() if _, err := s.Store.GetTenant(ctx, in.TenantID); err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } expiresAt := time.Now().UTC().Add(14 * 24 * time.Hour) tp, err := s.Store.UpsertTenantProduct(ctx, store.TenantProduct{ TenantID: in.TenantID, Product: in.Product, Enabled: true, Config: map[string]interface{}{"source": "trial"}, ExpiresAt: &expiresAt, }) if err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } s.emitAudit(ctx, r, store.AuditEvent{ TenantID: in.TenantID, Action: "catalog.trial_started", TargetID: in.Product, TargetType: "product", Metadata: map[string]interface{}{"product": in.Product, "expires_at": expiresAt.Format(time.RFC3339)}, }) writeJSON(w, http.StatusCreated, tp) } func isKnownProduct(key string) bool { _, ok := lookupCatalogEntry(key) return ok } func lookupCatalogEntry(key string) (store.CatalogEntry, bool) { for _, e := range catalog { if e.Key == key { return e, true } } return store.CatalogEntry{}, false }