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.
129 lines
3.7 KiB
Go
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,
|
|
})
|
|
}
|