Files
descrybe/apps/api/internal/httpapi/company_handlers.go
T
2026-08-17 00:39:25 +02:00

431 lines
14 KiB
Go

package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var (
id uuid.UUID
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, '{}'), owner_user_id
FROM companies WHERE id = $1`, cid).
Scan(&id, &name, &language, &merge, &contentLangs, &ownerUserID)
if err != nil {
Error(w, http.StatusNotFound, "company not found")
return
}
parsed, _ := company.ParseContentLanguages(contentLangs, language)
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) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Name *string `json:"name"`
Language *string `json:"language"`
ContentLanguages []string `json:"content_languages"`
MergeProductsByGTIN *bool `json:"merge_products_by_gtin"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
var languageArg any
primary := company.LoadLanguage(r.Context(), s.Pool, cid)
if body.Language != nil {
parsed, err := company.ParseLanguage(*body.Language, false)
if err != nil {
Error(w, http.StatusBadRequest, "unsupported language")
return
}
languageArg = parsed
primary = parsed
}
var contentLangsArg any
if body.ContentLanguages != nil {
parsed, err := company.ParseContentLanguages(body.ContentLanguages, primary)
if err != nil {
Error(w, http.StatusBadRequest, "unsupported language")
return
}
contentLangsArg = parsed
} else if body.Language != nil {
// Keep primary first when only language changes.
existing := company.LoadContentLanguages(r.Context(), s.Pool, cid)
parsed, err := company.ParseContentLanguages(existing, primary)
if err != nil {
parsed = []string{primary}
}
contentLangsArg = parsed
}
_, err := s.Pool.Exec(r.Context(), `
UPDATE companies SET
name = COALESCE($2, name),
language = COALESCE($3, language),
content_languages = COALESCE($5, content_languages),
merge_products_by_gtin = COALESCE($4, merge_products_by_gtin),
updated_at = now()
WHERE id = $1`, cid, body.Name, languageArg, body.MergeProductsByGTIN, contentLangsArg)
if err != nil {
Error(w, http.StatusInternalServerError, "update failed")
return
}
s.handleGetCompany(w, r)
}
func (s *Server) handleGetCompanySettings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
var settings []byte
err := s.Pool.QueryRow(r.Context(), `
SELECT settings FROM company_settings WHERE company_id = $1`, cid).Scan(&settings)
if err != nil {
JSON(w, http.StatusOK, map[string]any{"settings": map[string]any{}})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"settings":`))
_, _ = w.Write(settings)
_, _ = w.Write([]byte(`}`))
}
func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
return
}
var body struct {
Settings map[string]any `json:"settings"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := company.ValidateSettingsMap(body.Settings); err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
b, err := json.Marshal(body.Settings)
if err != nil {
Error(w, http.StatusBadRequest, "invalid settings")
return
}
_, err = s.Pool.Exec(r.Context(), `
INSERT INTO company_settings (company_id, settings, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (company_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now()`,
cid, b)
if err != nil {
Error(w, http.StatusInternalServerError, "save failed")
return
}
JSON(w, http.StatusOK, map[string]any{"settings": body.Settings})
}
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 {
Error(w, http.StatusInternalServerError, "list failed")
return
}
rows, err := s.Pool.Query(r.Context(), `
SELECT m.id, m.user_id, m.role, m.status, u.email, u.name
FROM memberships m JOIN users u ON u.id = m.user_id
WHERE m.company_id = $1 AND m.status = 'active' ORDER BY m.created_at LIMIT $2 OFFSET $3`, cid, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "list failed")
return
}
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"`
IsOwner bool `json:"is_owner"`
}
out := make([]member, 0)
for rows.Next() {
var m member
if err := rows.Scan(&m.ID, &m.UserID, &m.Role, &m.Status, &m.Email, &m.Name); err != nil {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
out = append(out, m)
}
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) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
var body struct {
Email string `json:"email"`
Role string `json:"role"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
inv, token, err := s.Auth.CreateInvite(r.Context(), cid, uid, body.Email, body.Role)
if err != nil {
ClientOrLog(w, http.StatusBadRequest, "could not create invite", err, auth.ClientError)
return
}
companyName, _ := s.Auth.CompanyName(r.Context(), cid)
smtpOn := s.Mail != nil && s.Mail.Enabled()
sendOK := false
if s.Mail != nil {
msg := mail.InviteMessage(s.Config.WebOrigin, inv.Email, token, companyName)
if err := s.Mail.Send(msg); err == nil {
sendOK = true
}
}
// noop/disabled mailers return nil from Send; only count real SMTP as delivered.
mailSent, includeToken := inviteMailResult(smtpOn, sendOK)
resp := map[string]any{
"id": inv.ID, "email": inv.Email, "role": inv.Role,
"expires_at": inv.ExpiresAt, "mail_sent": mailSent, "smtp_enabled": smtpOn,
}
// 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)
}
// inviteMailResult decides mail_sent and whether the accept token must be returned to the client.
func inviteMailResult(smtpEnabled, sendOK bool) (mailSent bool, includeToken bool) {
mailSent = smtpEnabled && sendOK
includeToken = !mailSent
return
}
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
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
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(&currentRole, &status)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "member not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if status == "active" && auth.NormalizeMembershipRole(currentRole) == "admin" {
var activeAdmins int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if blocksLastAdminRemove(activeAdmins) {
Error(w, http.StatusConflict, "cannot remove the last admin")
return
}
}
tag, err := s.Pool.Exec(r.Context(), `
UPDATE memberships SET status = 'inactive', updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID)
if err != nil {
Error(w, http.StatusInternalServerError, "remove failed")
return
}
if tag.RowsAffected() == 0 {
Error(w, http.StatusNotFound, "member not found")
return
}
JSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// blocksLastAdminDemote is true when demoting an admin would leave zero active admins.
func blocksLastAdminDemote(currentRole, newRole string, activeAdminCount int64) bool {
return currentRole == "admin" && newRole == "member" && activeAdminCount <= 1
}
// blocksLastAdminRemove is true when removing an admin would leave zero active admins.
func blocksLastAdminRemove(activeAdminCount int64) bool {
return activeAdminCount <= 1
}
func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request) {
if !s.allowCompanyAdminOrPlatform(w, r) {
return
}
cid, _ := CompanyIDFromContext(r.Context())
userID, err := uuid.Parse(chi.URLParam(r, "userID"))
if err != nil {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
var body struct {
Role string `json:"role"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
newRole, err := auth.ParseMembershipRole(body.Role)
if err != nil {
Error(w, http.StatusBadRequest, "invalid role")
return
}
var currentRole, status string
err = s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
WHERE company_id = $1 AND user_id = $2`, cid, userID).Scan(&currentRole, &status)
if errors.Is(err, pgx.ErrNoRows) {
Error(w, http.StatusNotFound, "member not found")
return
}
if err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if status != "active" {
Error(w, http.StatusBadRequest, "member is not active")
return
}
currentRole = auth.NormalizeMembershipRole(currentRole)
if currentRole == newRole {
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
return
}
if currentRole == "admin" && newRole == "member" {
var activeAdmins int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships
WHERE company_id = $1 AND role = 'admin' AND status = 'active'`, cid).Scan(&activeAdmins); err != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
}
if blocksLastAdminDemote(currentRole, newRole, activeAdmins) {
Error(w, http.StatusConflict, "cannot demote the last admin")
return
}
}
tag, err := s.Pool.Exec(r.Context(), `
UPDATE memberships SET role = $3, updated_at = now()
WHERE company_id = $1 AND user_id = $2 AND status = 'active'`, cid, userID, newRole)
if err != nil {
Error(w, http.StatusInternalServerError, "update failed")
return
}
if tag.RowsAffected() == 0 {
Error(w, http.StatusNotFound, "member not found")
return
}
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})
}