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,363 @@
|
||||
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
|
||||
)
|
||||
err := s.Pool.QueryRow(r.Context(), `
|
||||
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
|
||||
FROM companies WHERE id = $1`, cid).
|
||||
Scan(&id, &name, &language, &merge, &contentLangs)
|
||||
if err != nil {
|
||||
Error(w, http.StatusNotFound, "company not found")
|
||||
return
|
||||
}
|
||||
parsed, _ := company.ParseContentLanguages(contentLangs, language)
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"id": id, "name": name, "language": language,
|
||||
"content_languages": parsed, "merge_products_by_gtin": merge,
|
||||
})
|
||||
}
|
||||
|
||||
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 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"`
|
||||
}
|
||||
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
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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})
|
||||
}
|
||||
Reference in New Issue
Block a user