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"` // Restricted / DeniedCount summarise the owner-set access overlay so the team // table can badge restricted members without a request per row. Restricted bool `json:"restricted"` DeniedCount int `json:"denied_count"` } // Best effort: a missing permissions column (pre-migration) just means "no overlay". overlays, _ := s.Auth.MemberPermissionsByCompany(r.Context(), cid) 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 if !m.IsOwner { m.DeniedCount = len(deniedKeys(overlays[m.UserID])) m.Restricted = m.DeniedCount > 0 } 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 } // Invites are link-based: always return the one-time accept URL for the admin to share. // Email delivery is not used for team invites (SMTP may still be used for password reset, etc.). JSON(w, http.StatusCreated, map[string]any{ "id": inv.ID, "email": inv.Email, "role": inv.Role, "expires_at": inv.ExpiresAt, "mail_sent": false, "smtp_enabled": s.Mail != nil && s.Mail.Enabled(), "token": token, "accept_url": mail.AcceptInviteURL(s.Config.WebOrigin, token), }) } 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(¤tRole, &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(¤tRole, &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}) }