This commit is contained in:
2026-08-17 00:39:25 +02:00
parent 92f046b542
commit 93dc70123c
54 changed files with 25528 additions and 20666 deletions
@@ -0,0 +1,16 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
)
func TestAcceptInviteURL(t *testing.T) {
t.Parallel()
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
want := "http://localhost:28472/accept-invite?token=abc123"
if got != want {
t.Fatalf("AcceptInviteURL = %q want %q", got, want)
}
}
@@ -1,7 +1,10 @@
package httpapi
import (
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
@@ -12,6 +15,10 @@ import (
"github.com/google/uuid"
)
// syncA1MaxBodyBytes caps JSON or multipart Sync A1 payloads (dump + form fields).
// Slightly above MaxWPCategorySQLBytes for multipart overhead / base64 inflation.
const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
// handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
// host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
//
@@ -20,9 +27,13 @@ import (
// POST /api/admin/companies/{id}/sync-a1
// POST /api/admin/companies/{id}/fix-catalog (compat alias)
//
// Body: confirm=true required; backfill_categories (default true);
// Body (JSON): confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
// skip_dump_backfill (default false).
// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
// base64 of wp_product_categories.sql (primary for category prompts).
//
// Body (multipart/form-data): same fields as form values; file field
// wp_product_categories or wp_categories_sql for the SQL dump upload.
//
// Flash (UI): result → flash.admin.syncA1Success.
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
@@ -37,18 +48,14 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
return
}
var body struct {
Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
MySQLDump string `json:"mysql_dump"`
SkipDumpBackfill bool `json:"skip_dump_backfill"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
r.Body = http.MaxBytesReader(w, r.Body, syncA1MaxBodyBytes)
parsed, err := parseSyncA1Request(r)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
if !body.Confirm {
if !parsed.Confirm {
Error(w, http.StatusBadRequest, "confirm must be true")
return
}
@@ -67,12 +74,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
}
backfill := true
if body.BackfillCategories != nil {
backfill = *body.BackfillCategories
if parsed.BackfillCategories != nil {
backfill = *parsed.BackfillCategories
}
sampleLimit := 25
if body.ReprocessSampleLimit != nil {
sampleLimit = *body.ReprocessSampleLimit
if parsed.ReprocessSampleLimit != nil {
sampleLimit = *parsed.ReprocessSampleLimit
}
if sampleLimit < 0 {
sampleLimit = 0
@@ -92,9 +99,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
BackfillCategories: backfill,
ReprocessSampleLimit: sampleLimit,
AIPrompts: s.AIPrompts,
WPCategoriesSQL: parsed.WPCategoriesSQL,
},
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
SkipDumpBackfill: body.SkipDumpBackfill,
MySQLDumpPath: strings.TrimSpace(parsed.MySQLDump),
SkipDumpBackfill: parsed.SkipDumpBackfill,
},
)
if err != nil {
@@ -104,7 +112,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
note := "Synced in place (dump categories when available + hygiene); reprocess recommended products manually (no mass reprocess)."
if !result.DumpFound {
note = "Hygiene completed without dump backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy dump."
note = "Hygiene completed without product-dump category backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy product dump."
}
if len(parsed.WPCategoriesSQL) > 0 {
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
} else if strings.TrimSpace(result.WPCategoriesPath) == "" {
note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
}
JSON(w, http.StatusOK, map[string]any{
@@ -118,3 +131,135 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
s.handleAdminSyncCompanyA1(w, r)
}
type syncA1Request struct {
Confirm bool
BackfillCategories *bool
ReprocessSampleLimit *int
MySQLDump string
SkipDumpBackfill bool
WPCategoriesSQL []byte
}
func parseSyncA1Request(r *http.Request) (syncA1Request, error) {
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
if strings.HasPrefix(ct, "multipart/form-data") {
return parseSyncA1Multipart(r)
}
return parseSyncA1JSON(r)
}
func parseSyncA1JSON(r *http.Request) (syncA1Request, error) {
var body struct {
Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
MySQLDump string `json:"mysql_dump"`
SkipDumpBackfill bool `json:"skip_dump_backfill"`
WPProductCategoriesB64 string `json:"wp_product_categories_sql_b64"`
WPProductCategoriesPath string `json:"wp_product_categories"` // path-only; ignored for apply (upload required in prod)
}
if err := DecodeJSON(r, &body); err != nil {
return syncA1Request{}, errInvalidJSON
}
out := syncA1Request{
Confirm: body.Confirm,
BackfillCategories: body.BackfillCategories,
ReprocessSampleLimit: body.ReprocessSampleLimit,
MySQLDump: body.MySQLDump,
SkipDumpBackfill: body.SkipDumpBackfill,
}
_ = body.WPProductCategoriesPath // path paste is not enough for prod — ignore
b64 := strings.TrimSpace(body.WPProductCategoriesB64)
if b64 == "" {
return out, nil
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
// Allow URL-safe / raw without padding for convenience.
raw, err = base64.RawStdEncoding.DecodeString(b64)
if err != nil {
raw, err = base64.URLEncoding.DecodeString(b64)
}
if err != nil {
return syncA1Request{}, errWPCategoriesB64
}
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
return out, nil
}
func parseSyncA1Multipart(r *http.Request) (syncA1Request, error) {
if err := r.ParseMultipartForm(syncA1MaxBodyBytes); err != nil {
return syncA1Request{}, errInvalidMultipart
}
out := syncA1Request{
Confirm: parseFormBool(r.FormValue("confirm")),
MySQLDump: strings.TrimSpace(r.FormValue("mysql_dump")),
SkipDumpBackfill: parseFormBool(r.FormValue("skip_dump_backfill")),
}
if v := strings.TrimSpace(r.FormValue("backfill_categories")); v != "" {
b := parseFormBool(v)
out.BackfillCategories = &b
}
if v := strings.TrimSpace(r.FormValue("reprocess_sample_limit")); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return syncA1Request{}, errInvalidSampleLimit
}
out.ReprocessSampleLimit = &n
}
file, _, err := r.FormFile("wp_product_categories")
if err != nil {
file, _, err = r.FormFile("wp_categories_sql")
}
if err == nil {
defer file.Close()
raw, readErr := io.ReadAll(io.LimitReader(file, int64(catalog.MaxWPCategorySQLBytes)+1))
if readErr != nil {
return syncA1Request{}, errWPCategoriesRead
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
}
if b64 := strings.TrimSpace(r.FormValue("wp_product_categories_sql_b64")); b64 != "" && len(out.WPCategoriesSQL) == 0 {
raw, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return syncA1Request{}, errWPCategoriesB64
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
}
return out, nil
}
func parseFormBool(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
type syncA1ParseError string
func (e syncA1ParseError) Error() string { return string(e) }
var (
errInvalidJSON = syncA1ParseError("invalid json")
errInvalidMultipart = syncA1ParseError("invalid multipart form")
errInvalidSampleLimit = syncA1ParseError("invalid reprocess_sample_limit")
errWPCategoriesB64 = syncA1ParseError("invalid wp_product_categories_sql_b64")
errWPCategoriesTooLarge = syncA1ParseError("wp_product_categories.sql too large")
errWPCategoriesRead = syncA1ParseError("could not read wp_product_categories upload")
)
@@ -216,6 +216,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
skippedRateLimited := 0
skippedSend := 0
var singleToken string
var singleMode string
singleUser := body.UserID != nil
for _, uid := range targets {
@@ -251,6 +252,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
if err := s.Mail.Send(msg); err != nil {
log.Printf("admin set-password send failed user_id=%s", uid)
skippedSend++
if singleUser {
// Still return the one-time link so admins can share it while impersonating / offline SMTP.
singleToken = token
singleMode = mode
}
continue
}
if smtpOn {
@@ -258,6 +264,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
} else if singleUser {
// Share token only for single-user reissue when SMTP is off (no email in response).
singleToken = token
singleMode = mode
}
}
@@ -275,6 +282,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
}
if singleToken != "" {
resp["token"] = singleToken
if singleMode == "hmac" {
resp["accept_url"] = mail.SetPasswordURL(s.Config.WebOrigin, singleToken)
} else {
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, singleToken)
}
}
JSON(w, http.StatusOK, resp)
}
@@ -0,0 +1,78 @@
package httpapi
import (
"bytes"
"encoding/base64"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
)
func TestParseSyncA1JSON_Base64Upload(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('A','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
b64 := base64.StdEncoding.EncodeToString([]byte(sql))
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
parsed, err := parseSyncA1JSON(req)
if err != nil {
t.Fatal(err)
}
if !parsed.Confirm {
t.Fatal("confirm")
}
if string(parsed.WPCategoriesSQL) != sql {
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
}
}
func TestParseSyncA1JSON_RejectsOversizedBase64(t *testing.T) {
t.Parallel()
raw := make([]byte, catalog.MaxWPCategorySQLBytes+1)
b64 := base64.StdEncoding.EncodeToString(raw)
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
_, err := parseSyncA1JSON(req)
if err == nil {
t.Fatal("expected too-large error")
}
}
func TestParseSyncA1Multipart_FileUpload(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('B','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("confirm", "true"); err != nil {
t.Fatal(err)
}
part, err := w.CreateFormFile("wp_product_categories", "wp_product_categories.sql")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(sql)); err != nil {
t.Fatal(err)
}
ct := w.FormDataContentType()
if err := w.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(buf.Bytes()))
req.Header.Set("Content-Type", ct)
parsed, err := parseSyncA1Multipart(req)
if err != nil {
t.Fatal(err)
}
if !parsed.Confirm {
t.Fatal("confirm")
}
if string(parsed.WPCategoriesSQL) != sql {
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
}
}
@@ -347,9 +347,13 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
}
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
if isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid); oerr == nil {
out["is_owner"] = isOwner
}
} else if staffTenantSwitch && errors.Is(err, auth.ErrNotCompanyMember) {
staffOverride = true
out["membership"] = map[string]any{"role": "admin", "status": "active", "staff_override": true}
out["is_owner"] = false
}
}
if staffTenantSwitch && homeStr != "" {
+78 -11
View File
@@ -20,20 +20,25 @@ func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
name, language string
merge bool
contentLangs []string
ownerUserID *uuid.UUID
)
err := s.Pool.QueryRow(r.Context(), `
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}'), owner_user_id
FROM companies WHERE id = $1`, cid).
Scan(&id, &name, &language, &merge, &contentLangs)
Scan(&id, &name, &language, &merge, &contentLangs, &ownerUserID)
if err != nil {
Error(w, http.StatusNotFound, "company not found")
return
}
parsed, _ := company.ParseContentLanguages(contentLangs, language)
JSON(w, http.StatusOK, map[string]any{
out := map[string]any{
"id": id, "name": name, "language": language,
"content_languages": parsed, "merge_products_by_gtin": merge,
})
}
if ownerUserID != nil {
out["owner_user_id"] = *ownerUserID
}
JSON(w, http.StatusOK, out)
}
func (s *Server) handleUpdateCompany(w http.ResponseWriter, r *http.Request) {
@@ -150,6 +155,8 @@ func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
var ownerUserID *uuid.UUID
_ = s.Pool.QueryRow(r.Context(), `SELECT owner_user_id FROM companies WHERE id = $1`, cid).Scan(&ownerUserID)
var total int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships m WHERE m.company_id = $1 AND m.status = 'active'`, cid).Scan(&total); err != nil {
@@ -166,12 +173,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
type member struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
Email string `json:"email"`
Name *string `json:"name"`
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Role string `json:"role"`
Status string `json:"status"`
Email string `json:"email"`
Name *string `json:"name"`
IsOwner bool `json:"is_owner"`
}
out := make([]member, 0)
for rows.Next() {
@@ -180,9 +188,14 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
out = append(out, m)
}
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
if ownerUserID != nil {
resp["owner_user_id"] = *ownerUserID
}
JSON(w, http.StatusOK, resp)
}
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
@@ -222,6 +235,7 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
// Token returned when email was not delivered so operators can share the accept link.
if includeToken {
resp["token"] = token
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, token)
}
JSON(w, http.StatusCreated, resp)
}
@@ -243,6 +257,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
if ownerID, hasOwner, oerr := s.Auth.CompanyOwnerID(r.Context(), cid); oerr != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
} else if hasOwner && ownerID == userID {
Error(w, http.StatusConflict, auth.ErrCannotRemoveOwner.Error())
return
}
var currentRole, status string
err = s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
@@ -361,3 +382,49 @@ func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request)
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
}
func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return
}
if !platformAdmin {
isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid)
if oerr != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return
}
if !isOwner {
Error(w, http.StatusForbidden, auth.ErrNotCompanyOwner.Error())
return
}
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := DecodeJSON(r, &body); err != nil || body.UserID == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Auth.TransferOwnership(r.Context(), cid, body.UserID); err != nil {
switch {
case errors.Is(err, auth.ErrTransferSelf):
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
case errors.Is(err, auth.ErrOwnerRequired), errors.Is(err, auth.ErrNotCompanyMember):
Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrCompanyNotFound):
Error(w, http.StatusNotFound, err.Error())
default:
ClientOrLog(w, http.StatusBadRequest, "could not transfer ownership", err, auth.ClientError)
}
return
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
}
+43
View File
@@ -89,6 +89,49 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
return false
}
// allowCompanyOwnerOrPlatform allows the company billing owner or a platform admin.
// When owner_user_id is unset (pre-backfill), falls back to company admin so billing is not locked out.
func (s *Server) allowCompanyOwnerOrPlatform(w http.ResponseWriter, r *http.Request) bool {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return false
}
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if isAdmin {
return true
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusForbidden, "company required")
return false
}
if s.Auth != nil {
ownerID, hasOwner, err := s.Auth.CompanyOwnerID(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if hasOwner {
if ownerID == uid {
return true
}
Error(w, http.StatusForbidden, "company owner required")
return false
}
}
// Legacy fallback before owner backfill, or unit tests without Auth wired.
if CompanyAdminAllowed(r.Context()) {
return true
}
Error(w, http.StatusForbidden, "company owner required")
return false
}
func (s *Server) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
+1
View File
@@ -447,6 +447,7 @@ func (s *Server) Router() http.Handler {
r.Post("/team/invites", s.handleCreateInvite)
r.Get("/team/invites", s.handleListInvites)
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
r.Delete("/team/{userID}", s.handleRemoveMember)
+2 -6
View File
@@ -40,9 +40,7 @@ func (s *Server) handleStripeStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
if !s.allowCompanyOwnerOrPlatform(w, r) {
return
}
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
@@ -73,9 +71,7 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStripePortal(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
if !s.allowCompanyOwnerOrPlatform(w, r) {
return
}
res, err := s.stripeSvc().CreatePortalSession(r.Context(), cid)