- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465), html/template rendering, branded base layout + content templates, silent no-op when SMTP_* unset. - internal/services/reminder_service.go: hourly scanner for Fristen that are overdue / due tomorrow / due within the week (Monday digest). Dedup via paliad.reminder_log (24h window). - internal/services/invite_service.go: POST /api/invite flow with domain whitelist, in-memory 10/day/user rate limit, audit row in paliad.invitations. - internal/handlers/invite.go: POST + GET /api/invite handlers. - Sidebar "Kolleg:in einladen" button + modal on every page. - migration 016: paliad.reminder_log, paliad.invitations, users.lang column. - docker-compose: SMTP_* + PALIAD_BASE_URL env vars. - docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open question; current pilot keeps identity mails on Supabase default sender. Rationale: get Paliad off Supabase's best-effort outbound for the inbox-facing stuff (reminders, invitations) and move deadline nudges from passive dashboard to active email. Custom Supabase auth SMTP is blocked on the shared ydb.youpc.org instance — deferred until Paliad has its own project or GoTrue webhook relay.
128 lines
3.6 KiB
Go
128 lines
3.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"mgit.msbls.de/m/patholo/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 HLC 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 HLC 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,
|
|
})
|
|
}
|