Files
paliad/internal/handlers/invite.go
m 460736ad1e refactor(t-paliad-092): rename Go module path patholo → paliad
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.

Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
  i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
  global.css

Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
2026-04-30 16:46:31 +02:00

129 lines
3.7 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
// inviteRequest is the JSON body for POST /api/invite. Both fields are
// optional-ish: email is required and validated, message is a free-form
// personal note the sender may include. Kept minimal on purpose — the
// invitation UX is a single email field plus an optional textarea.
type inviteRequest struct {
Email string `json:"email"`
Message string `json:"message,omitempty"`
}
type inviteResponse struct {
ID string `json:"id"`
Remaining int `json:"remaining_today"`
}
// POST /api/invite — send a branded invitation email to a colleague.
//
// Auth: any onboarded user may invite. The firm email-domain whitelist
// (ALLOWED_EMAIL_DOMAINS) is enforced in-service so callers can't bypass it
// by bypassing the handler.
//
// Rate limit: 10 invitations per 24h per user, enforced in InviteService.
// The response always includes `remaining_today` so the UI can show
// "3 invitations left" without a second request.
func handleInvite(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.invite == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "invitations unavailable — SMTP not configured",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
me, err := dbSvc.users.GetByID(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if me == nil {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "onboarding required before sending invitations",
})
return
}
var req inviteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
id, err := dbSvc.invite.Send(r.Context(), uid,
services.Inviter{
DisplayName: me.DisplayName,
Email: me.Email,
Lang: me.Lang,
},
services.InviteInput{
ToEmail: req.Email,
Message: req.Message,
},
)
if err != nil {
switch {
case errors.Is(err, services.ErrInviteInvalidEmail):
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid email address",
})
case errors.Is(err, services.ErrInviteDomainBlocked):
writeJSON(w, http.StatusForbidden, map[string]string{
"error": "recipient domain not on the " + branding.Name + " allow-list",
})
case errors.Is(err, services.ErrInviteRateLimited):
w.Header().Set("Retry-After", "3600")
writeJSON(w, http.StatusTooManyRequests, map[string]string{
"error": "invitation rate limit reached — try again tomorrow",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "failed to send invitation",
})
}
return
}
writeJSON(w, http.StatusCreated, inviteResponse{
ID: id.String(),
Remaining: dbSvc.invite.RemainingToday(uid),
})
}
// GET /api/invite — read-only status: how many invitation slots the caller
// has left in the current 24h window. Cheap to call; frontend can poll this
// before opening the modal to avoid disabling the send button post-hoc.
func handleInviteStatus(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.invite == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "invitations unavailable — SMTP not configured",
})
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, map[string]int{
"remaining_today": dbSvc.invite.RemainingToday(uid),
"limit": services.InviteRateLimit,
})
}