Initial commit: breakpilot-core - Shared Infrastructure

Docker Compose with 24+ services:
- PostgreSQL (PostGIS), Valkey, MinIO, Qdrant
- Vault (PKI/TLS), Nginx (Reverse Proxy)
- Backend Core API, Consent Service, Billing Service
- RAG Service, Embedding Service
- Gitea, Woodpecker CI/CD
- Night Scheduler, Health Aggregator
- Jitsi (Web/XMPP/JVB/Jicofo), Mailpit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:13 +01:00
commit ad111d5e69
244 changed files with 84288 additions and 0 deletions

View File

@@ -0,0 +1,442 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
)
// AuthHandler handles authentication endpoints
type AuthHandler struct {
authService *services.AuthService
emailService *services.EmailService
}
// NewAuthHandler creates a new AuthHandler
func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService) *AuthHandler {
return &AuthHandler{
authService: authService,
emailService: emailService,
}
}
// Register handles user registration
// @Summary Register a new user
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.RegisterRequest true "Registration data"
// @Success 201 {object} map[string]interface{}
// @Failure 400 {object} map[string]string
// @Failure 409 {object} map[string]string
// @Router /auth/register [post]
func (h *AuthHandler) Register(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
user, verificationToken, err := h.authService.Register(c.Request.Context(), &req)
if err != nil {
if err == services.ErrUserExists {
c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register user"})
return
}
// Send verification email (async, don't block response)
go func() {
var name string
if user.Name != nil {
name = *user.Name
}
if err := h.emailService.SendVerificationEmail(user.Email, name, verificationToken); err != nil {
// Log error but don't fail registration
println("Failed to send verification email:", err.Error())
}
}()
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please check your email to verify your account.",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
},
})
}
// Login handles user login
// @Summary Login user
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.LoginRequest true "Login credentials"
// @Success 200 {object} models.LoginResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /auth/login [post]
func (h *AuthHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
response, err := h.authService.Login(c.Request.Context(), &req, ipAddress, userAgent)
if err != nil {
switch err {
case services.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
case services.ErrAccountLocked:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked. Please try again later."})
case services.ErrAccountSuspended:
c.JSON(http.StatusForbidden, gin.H{
"error": "Account is suspended",
"reason": "consent_required",
"redirect": "/consent/pending",
})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
}
return
}
c.JSON(http.StatusOK, response)
}
// Logout handles user logout
// @Summary Logout user
// @Tags auth
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer token"
// @Success 200 {object} map[string]string
// @Router /auth/logout [post]
func (h *AuthHandler) Logout(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" {
_ = h.authService.Logout(c.Request.Context(), req.RefreshToken)
}
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
// RefreshToken refreshes the access token
// @Summary Refresh access token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.RefreshTokenRequest true "Refresh token"
// @Success 200 {object} models.LoginResponse
// @Failure 401 {object} map[string]string
// @Router /auth/refresh [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req models.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
response, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
if err == services.ErrAccountSuspended {
c.JSON(http.StatusForbidden, gin.H{
"error": "Account is suspended",
"reason": "consent_required",
"redirect": "/consent/pending",
})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired refresh token"})
return
}
c.JSON(http.StatusOK, response)
}
// VerifyEmail verifies user email
// @Summary Verify email address
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.VerifyEmailRequest true "Verification token"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /auth/verify-email [post]
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
var req models.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := h.authService.VerifyEmail(c.Request.Context(), req.Token); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification token"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully. You can now log in."})
}
// ResendVerification resends verification email
// @Summary Resend verification email
// @Tags auth
// @Accept json
// @Produce json
// @Param request body map[string]string true "Email"
// @Success 200 {object} map[string]string
// @Router /auth/resend-verification [post]
func (h *AuthHandler) ResendVerification(c *gin.Context) {
var req struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, gin.H{"message": "If an account exists with this email, a verification email has been sent."})
}
// ForgotPassword initiates password reset
// @Summary Request password reset
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.ForgotPasswordRequest true "Email"
// @Success 200 {object} map[string]string
// @Router /auth/forgot-password [post]
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var req models.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
token, userID, err := h.authService.CreatePasswordResetToken(c.Request.Context(), req.Email, c.ClientIP())
if err == nil && userID != nil {
// Send email asynchronously
go func() {
_ = h.emailService.SendPasswordResetEmail(req.Email, "", token)
}()
}
// Always return success to prevent email enumeration
c.JSON(http.StatusOK, gin.H{"message": "If an account exists with this email, a password reset link has been sent."})
}
// ResetPassword resets password with token
// @Summary Reset password
// @Tags auth
// @Accept json
// @Produce json
// @Param request body models.ResetPasswordRequest true "Reset token and new password"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /auth/reset-password [post]
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req models.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
if err := h.authService.ResetPassword(c.Request.Context(), req.Token, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully. You can now log in with your new password."})
}
// GetProfile returns the current user's profile
// @Summary Get user profile
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.User
// @Failure 401 {object} map[string]string
// @Router /profile [get]
func (h *AuthHandler) GetProfile(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"})
return
}
user, err := h.authService.GetUserByID(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
c.JSON(http.StatusOK, user)
}
// UpdateProfile updates the current user's profile
// @Summary Update user profile
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.UpdateProfileRequest true "Profile data"
// @Success 200 {object} models.User
// @Failure 400 {object} map[string]string
// @Router /profile [put]
func (h *AuthHandler) UpdateProfile(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"})
return
}
var req models.UpdateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
user, err := h.authService.UpdateProfile(c.Request.Context(), userID, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
return
}
c.JSON(http.StatusOK, user)
}
// ChangePassword changes the current user's password
// @Summary Change password
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.ChangePasswordRequest true "Password data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Router /profile/password [put]
func (h *AuthHandler) ChangePassword(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"})
return
}
var req models.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()})
return
}
if err := h.authService.ChangePassword(c.Request.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil {
if err == services.ErrInvalidCredentials {
c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to change password"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"})
}
// GetActiveSessions returns all active sessions for the current user
// @Summary Get active sessions
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {array} models.UserSession
// @Router /profile/sessions [get]
func (h *AuthHandler) GetActiveSessions(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"})
return
}
sessions, err := h.authService.GetActiveSessions(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sessions"})
return
}
c.JSON(http.StatusOK, gin.H{"sessions": sessions})
}
// RevokeSession revokes a specific session
// @Summary Revoke session
// @Tags profile
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Session ID"
// @Success 200 {object} map[string]string
// @Failure 404 {object} map[string]string
// @Router /profile/sessions/{id} [delete]
func (h *AuthHandler) RevokeSession(c *gin.Context) {
userIDStr, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
userID, err := uuid.Parse(userIDStr.(string))
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"})
return
}
sessionID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"})
return
}
if err := h.authService.RevokeSession(c.Request.Context(), userID, sessionID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Session revoked successfully"})
}

View File

@@ -0,0 +1,561 @@
package handlers
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ========================================
// Cookie Banner SDK API Handlers
// ========================================
// Diese Endpoints werden vom @breakpilot/consent-sdk verwendet
// für anonyme (device-basierte) Cookie-Einwilligungen.
// BannerConsentRecord repräsentiert einen anonymen Consent-Eintrag
type BannerConsentRecord struct {
ID string `json:"id"`
SiteID string `json:"site_id"`
DeviceFingerprint string `json:"device_fingerprint"`
UserID *string `json:"user_id,omitempty"`
Categories map[string]bool `json:"categories"`
Vendors map[string]bool `json:"vendors,omitempty"`
TCFString *string `json:"tcf_string,omitempty"`
IPHash *string `json:"ip_hash,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
Language *string `json:"language,omitempty"`
Platform *string `json:"platform,omitempty"`
AppVersion *string `json:"app_version,omitempty"`
Version string `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// BannerConsentRequest ist der Request-Body für POST /consent
type BannerConsentRequest struct {
SiteID string `json:"siteId" binding:"required"`
UserID *string `json:"userId,omitempty"`
DeviceFingerprint string `json:"deviceFingerprint" binding:"required"`
Consent ConsentData `json:"consent" binding:"required"`
Metadata *ConsentMetadata `json:"metadata,omitempty"`
}
// ConsentData enthält die eigentlichen Consent-Daten
type ConsentData struct {
Categories map[string]bool `json:"categories" binding:"required"`
Vendors map[string]bool `json:"vendors,omitempty"`
}
// ConsentMetadata enthält optionale Metadaten
type ConsentMetadata struct {
UserAgent *string `json:"userAgent,omitempty"`
Language *string `json:"language,omitempty"`
ScreenResolution *string `json:"screenResolution,omitempty"`
Platform *string `json:"platform,omitempty"`
AppVersion *string `json:"appVersion,omitempty"`
}
// BannerConsentResponse ist die Antwort auf POST /consent
type BannerConsentResponse struct {
ConsentID string `json:"consentId"`
Timestamp string `json:"timestamp"`
ExpiresAt string `json:"expiresAt"`
Version string `json:"version"`
}
// SiteConfig repräsentiert die Konfiguration für eine Site
type SiteConfig struct {
SiteID string `json:"siteId"`
SiteName string `json:"siteName"`
Categories []CategoryConfig `json:"categories"`
UI UIConfig `json:"ui"`
Legal LegalConfig `json:"legal"`
TCF *TCFConfig `json:"tcf,omitempty"`
}
// CategoryConfig repräsentiert eine Consent-Kategorie
type CategoryConfig struct {
ID string `json:"id"`
Name map[string]string `json:"name"`
Description map[string]string `json:"description"`
Required bool `json:"required"`
Vendors []VendorConfig `json:"vendors"`
}
// VendorConfig repräsentiert einen Vendor (Third-Party)
type VendorConfig struct {
ID string `json:"id"`
Name string `json:"name"`
PrivacyPolicyURL string `json:"privacyPolicyUrl"`
Cookies []CookieInfo `json:"cookies"`
}
// CookieInfo repräsentiert ein Cookie
type CookieInfo struct {
Name string `json:"name"`
Expiration string `json:"expiration"`
Description string `json:"description"`
}
// UIConfig repräsentiert UI-Einstellungen
type UIConfig struct {
Theme string `json:"theme"`
Position string `json:"position"`
}
// LegalConfig repräsentiert rechtliche Informationen
type LegalConfig struct {
PrivacyPolicyURL string `json:"privacyPolicyUrl"`
ImprintURL string `json:"imprintUrl"`
}
// TCFConfig repräsentiert TCF 2.2 Einstellungen
type TCFConfig struct {
Enabled bool `json:"enabled"`
CmpID int `json:"cmpId"`
CmpVersion int `json:"cmpVersion"`
}
// ========================================
// Handler Methods
// ========================================
// CreateBannerConsent erstellt oder aktualisiert einen Consent-Eintrag
// POST /api/v1/banner/consent
func (h *Handler) CreateBannerConsent(c *gin.Context) {
var req BannerConsentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"message": "Invalid request body: " + err.Error(),
})
return
}
ctx := context.Background()
// IP-Adresse anonymisieren
ipHash := anonymizeIP(c.ClientIP())
// Consent-ID generieren
consentID := uuid.New().String()
// Ablaufdatum (1 Jahr)
expiresAt := time.Now().AddDate(1, 0, 0)
// Categories und Vendors als JSON
categoriesJSON, _ := json.Marshal(req.Consent.Categories)
vendorsJSON, _ := json.Marshal(req.Consent.Vendors)
// Metadaten extrahieren
var userAgent, language, platform, appVersion *string
if req.Metadata != nil {
userAgent = req.Metadata.UserAgent
language = req.Metadata.Language
platform = req.Metadata.Platform
appVersion = req.Metadata.AppVersion
}
// In Datenbank speichern
_, err := h.db.Pool.Exec(ctx, `
INSERT INTO banner_consents (
id, site_id, device_fingerprint, user_id,
categories, vendors, ip_hash, user_agent,
language, platform, app_version, version,
expires_at, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
ON CONFLICT (site_id, device_fingerprint)
DO UPDATE SET
categories = $5,
vendors = $6,
ip_hash = $7,
user_agent = $8,
language = $9,
platform = $10,
app_version = $11,
version = $12,
expires_at = $13,
updated_at = NOW()
RETURNING id
`, consentID, req.SiteID, req.DeviceFingerprint, req.UserID,
categoriesJSON, vendorsJSON, ipHash, userAgent,
language, platform, appVersion, "1.0.0", expiresAt)
if err != nil {
// Fallback: Existierenden Consent abrufen
var existingID string
err2 := h.db.Pool.QueryRow(ctx, `
SELECT id FROM banner_consents
WHERE site_id = $1 AND device_fingerprint = $2
`, req.SiteID, req.DeviceFingerprint).Scan(&existingID)
if err2 == nil {
consentID = existingID
}
}
// Audit-Log schreiben
h.logBannerConsentAudit(ctx, consentID, "created", req, ipHash)
// Response
c.JSON(http.StatusCreated, BannerConsentResponse{
ConsentID: consentID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
Version: "1.0.0",
})
}
// GetBannerConsent ruft einen bestehenden Consent ab
// GET /api/v1/banner/consent?siteId=xxx&deviceFingerprint=xxx
func (h *Handler) GetBannerConsent(c *gin.Context) {
siteID := c.Query("siteId")
deviceFingerprint := c.Query("deviceFingerprint")
if siteID == "" || deviceFingerprint == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "missing_parameters",
"message": "siteId and deviceFingerprint are required",
})
return
}
ctx := context.Background()
var record BannerConsentRecord
var categoriesJSON, vendorsJSON []byte
err := h.db.Pool.QueryRow(ctx, `
SELECT id, site_id, device_fingerprint, user_id,
categories, vendors, version,
created_at, updated_at, expires_at, revoked_at
FROM banner_consents
WHERE site_id = $1 AND device_fingerprint = $2 AND revoked_at IS NULL
`, siteID, deviceFingerprint).Scan(
&record.ID, &record.SiteID, &record.DeviceFingerprint, &record.UserID,
&categoriesJSON, &vendorsJSON, &record.Version,
&record.CreatedAt, &record.UpdatedAt, &record.ExpiresAt, &record.RevokedAt,
)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "consent_not_found",
"message": "No consent record found",
})
return
}
// JSON parsen
json.Unmarshal(categoriesJSON, &record.Categories)
json.Unmarshal(vendorsJSON, &record.Vendors)
c.JSON(http.StatusOK, gin.H{
"consentId": record.ID,
"consent": gin.H{
"categories": record.Categories,
"vendors": record.Vendors,
},
"createdAt": record.CreatedAt.UTC().Format(time.RFC3339),
"updatedAt": record.UpdatedAt.UTC().Format(time.RFC3339),
"expiresAt": record.ExpiresAt.UTC().Format(time.RFC3339),
"version": record.Version,
})
}
// RevokeBannerConsent widerruft einen Consent
// DELETE /api/v1/banner/consent/:consentId
func (h *Handler) RevokeBannerConsent(c *gin.Context) {
consentID := c.Param("consentId")
ctx := context.Background()
result, err := h.db.Pool.Exec(ctx, `
UPDATE banner_consents
SET revoked_at = NOW(), updated_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`, consentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "revoke_failed",
"message": "Failed to revoke consent",
})
return
}
if result.RowsAffected() == 0 {
c.JSON(http.StatusNotFound, gin.H{
"error": "consent_not_found",
"message": "Consent not found or already revoked",
})
return
}
// Audit-Log
h.logBannerConsentAudit(ctx, consentID, "revoked", nil, anonymizeIP(c.ClientIP()))
c.JSON(http.StatusOK, gin.H{
"status": "revoked",
"revokedAt": time.Now().UTC().Format(time.RFC3339),
})
}
// GetSiteConfig gibt die Konfiguration für eine Site zurück
// GET /api/v1/banner/config/:siteId
func (h *Handler) GetSiteConfig(c *gin.Context) {
siteID := c.Param("siteId")
// Standard-Kategorien (aus Datenbank oder Default)
categories := []CategoryConfig{
{
ID: "essential",
Name: map[string]string{
"de": "Essentiell",
"en": "Essential",
},
Description: map[string]string{
"de": "Notwendig für die Grundfunktionen der Website.",
"en": "Required for basic website functionality.",
},
Required: true,
Vendors: []VendorConfig{},
},
{
ID: "functional",
Name: map[string]string{
"de": "Funktional",
"en": "Functional",
},
Description: map[string]string{
"de": "Ermöglicht Personalisierung und Komfortfunktionen.",
"en": "Enables personalization and comfort features.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "analytics",
Name: map[string]string{
"de": "Statistik",
"en": "Analytics",
},
Description: map[string]string{
"de": "Hilft uns, die Website zu verbessern.",
"en": "Helps us improve the website.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "marketing",
Name: map[string]string{
"de": "Marketing",
"en": "Marketing",
},
Description: map[string]string{
"de": "Ermöglicht personalisierte Werbung.",
"en": "Enables personalized advertising.",
},
Required: false,
Vendors: []VendorConfig{},
},
{
ID: "social",
Name: map[string]string{
"de": "Soziale Medien",
"en": "Social Media",
},
Description: map[string]string{
"de": "Ermöglicht Inhalte von sozialen Netzwerken.",
"en": "Enables content from social networks.",
},
Required: false,
Vendors: []VendorConfig{},
},
}
config := SiteConfig{
SiteID: siteID,
SiteName: "BreakPilot",
Categories: categories,
UI: UIConfig{
Theme: "auto",
Position: "bottom",
},
Legal: LegalConfig{
PrivacyPolicyURL: "/datenschutz",
ImprintURL: "/impressum",
},
}
c.JSON(http.StatusOK, config)
}
// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20)
// GET /api/v1/banner/consent/export?userId=xxx
func (h *Handler) ExportBannerConsent(c *gin.Context) {
userID := c.Query("userId")
if userID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "missing_user_id",
"message": "userId parameter is required",
})
return
}
ctx := context.Background()
rows, err := h.db.Pool.Query(ctx, `
SELECT id, site_id, device_fingerprint, categories, vendors,
version, created_at, updated_at, revoked_at
FROM banner_consents
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "export_failed",
"message": "Failed to export consent data",
})
return
}
defer rows.Close()
var consents []map[string]interface{}
for rows.Next() {
var id, siteID, deviceFingerprint, version string
var categoriesJSON, vendorsJSON []byte
var createdAt, updatedAt time.Time
var revokedAt *time.Time
rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON,
&version, &createdAt, &updatedAt, &revokedAt)
var categories, vendors map[string]bool
json.Unmarshal(categoriesJSON, &categories)
json.Unmarshal(vendorsJSON, &vendors)
consent := map[string]interface{}{
"consentId": id,
"siteId": siteID,
"consent": map[string]interface{}{
"categories": categories,
"vendors": vendors,
},
"createdAt": createdAt.UTC().Format(time.RFC3339),
"revokedAt": nil,
}
if revokedAt != nil {
consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339)
}
consents = append(consents, consent)
}
c.JSON(http.StatusOK, gin.H{
"userId": userID,
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"consents": consents,
})
}
// GetBannerStats gibt anonymisierte Statistiken zurück (Admin)
// GET /api/v1/banner/admin/stats/:siteId
func (h *Handler) GetBannerStats(c *gin.Context) {
siteID := c.Param("siteId")
ctx := context.Background()
// Gesamtanzahl Consents
var totalConsents int
h.db.Pool.QueryRow(ctx, `
SELECT COUNT(*) FROM banner_consents
WHERE site_id = $1 AND revoked_at IS NULL
`, siteID).Scan(&totalConsents)
// Consent-Rate pro Kategorie
categoryStats := make(map[string]map[string]interface{})
rows, _ := h.db.Pool.Query(ctx, `
SELECT
key as category,
COUNT(*) FILTER (WHERE value::text = 'true') as accepted,
COUNT(*) as total
FROM banner_consents,
jsonb_each(categories::jsonb)
WHERE site_id = $1 AND revoked_at IS NULL
GROUP BY key
`, siteID)
if rows != nil {
defer rows.Close()
for rows.Next() {
var category string
var accepted, total int
rows.Scan(&category, &accepted, &total)
rate := float64(0)
if total > 0 {
rate = float64(accepted) / float64(total)
}
categoryStats[category] = map[string]interface{}{
"accepted": accepted,
"rate": rate,
}
}
}
c.JSON(http.StatusOK, gin.H{
"siteId": siteID,
"period": gin.H{
"from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"),
"to": time.Now().Format("2006-01-02"),
},
"totalConsents": totalConsents,
"consentByCategory": categoryStats,
})
}
// ========================================
// Helper Functions
// ========================================
// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform)
func anonymizeIP(ip string) string {
// IPv4: Letztes Oktett auf 0
parts := strings.Split(ip, ".")
if len(parts) == 4 {
parts[3] = "0"
anonymized := strings.Join(parts, ".")
hash := sha256.Sum256([]byte(anonymized))
return hex.EncodeToString(hash[:])[:16]
}
// IPv6: Hash
hash := sha256.Sum256([]byte(ip))
return hex.EncodeToString(hash[:])[:16]
}
// logBannerConsentAudit schreibt einen Audit-Log-Eintrag
func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) {
details, _ := json.Marshal(req)
h.db.Pool.Exec(ctx, `
INSERT INTO banner_consent_audit_log (
id, consent_id, action, details, ip_hash, created_at
) VALUES ($1, $2, $3, $4, $5, NOW())
`, uuid.New().String(), consentID, action, string(details), ipHash)
}

View File

@@ -0,0 +1,511 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/services/jitsi"
"github.com/breakpilot/consent-service/internal/services/matrix"
"github.com/gin-gonic/gin"
)
// CommunicationHandlers handles Matrix and Jitsi API endpoints
type CommunicationHandlers struct {
matrixService *matrix.MatrixService
jitsiService *jitsi.JitsiService
}
// NewCommunicationHandlers creates new communication handlers
func NewCommunicationHandlers(matrixSvc *matrix.MatrixService, jitsiSvc *jitsi.JitsiService) *CommunicationHandlers {
return &CommunicationHandlers{
matrixService: matrixSvc,
jitsiService: jitsiSvc,
}
}
// ========================================
// Health & Status Endpoints
// ========================================
// GetCommunicationStatus returns status of Matrix and Jitsi services
func (h *CommunicationHandlers) GetCommunicationStatus(c *gin.Context) {
ctx := c.Request.Context()
status := gin.H{
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
// Check Matrix
if h.matrixService != nil {
matrixErr := h.matrixService.HealthCheck(ctx)
status["matrix"] = gin.H{
"enabled": true,
"healthy": matrixErr == nil,
"server_name": h.matrixService.GetServerName(),
"error": errToString(matrixErr),
}
} else {
status["matrix"] = gin.H{
"enabled": false,
"healthy": false,
}
}
// Check Jitsi
if h.jitsiService != nil {
jitsiErr := h.jitsiService.HealthCheck(ctx)
serverInfo := h.jitsiService.GetServerInfo()
status["jitsi"] = gin.H{
"enabled": true,
"healthy": jitsiErr == nil,
"base_url": serverInfo["base_url"],
"auth_enabled": serverInfo["auth_enabled"],
"error": errToString(jitsiErr),
}
} else {
status["jitsi"] = gin.H{
"enabled": false,
"healthy": false,
}
}
c.JSON(http.StatusOK, status)
}
// ========================================
// Matrix Room Endpoints
// ========================================
// CreateRoomRequest for creating Matrix rooms
type CreateRoomRequest struct {
Type string `json:"type" binding:"required"` // "class_info", "student_dm", "parent_rep"
ClassName string `json:"class_name"`
SchoolName string `json:"school_name"`
StudentName string `json:"student_name,omitempty"`
TeacherIDs []string `json:"teacher_ids"`
ParentIDs []string `json:"parent_ids,omitempty"`
ParentRepIDs []string `json:"parent_rep_ids,omitempty"`
}
// CreateRoom creates a new Matrix room based on type
func (h *CommunicationHandlers) CreateRoom(c *gin.Context) {
if h.matrixService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"})
return
}
var req CreateRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var resp *matrix.CreateRoomResponse
var err error
switch req.Type {
case "class_info":
resp, err = h.matrixService.CreateClassInfoRoom(ctx, req.ClassName, req.SchoolName, req.TeacherIDs)
case "student_dm":
resp, err = h.matrixService.CreateStudentDMRoom(ctx, req.StudentName, req.ClassName, req.TeacherIDs, req.ParentIDs)
case "parent_rep":
resp, err = h.matrixService.CreateParentRepRoom(ctx, req.ClassName, req.TeacherIDs, req.ParentRepIDs)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room type. Use: class_info, student_dm, parent_rep"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"room_id": resp.RoomID,
"type": req.Type,
})
}
// InviteUserRequest for inviting users to rooms
type InviteUserRequest struct {
RoomID string `json:"room_id" binding:"required"`
UserID string `json:"user_id" binding:"required"`
}
// InviteUser invites a user to a Matrix room
func (h *CommunicationHandlers) InviteUser(c *gin.Context) {
if h.matrixService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"})
return
}
var req InviteUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
if err := h.matrixService.InviteUser(ctx, req.RoomID, req.UserID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SendMessageRequest for sending messages
type SendMessageRequest struct {
RoomID string `json:"room_id" binding:"required"`
Message string `json:"message" binding:"required"`
HTML string `json:"html,omitempty"`
}
// SendMessage sends a message to a Matrix room
func (h *CommunicationHandlers) SendMessage(c *gin.Context) {
if h.matrixService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"})
return
}
var req SendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var err error
if req.HTML != "" {
err = h.matrixService.SendHTMLMessage(ctx, req.RoomID, req.Message, req.HTML)
} else {
err = h.matrixService.SendMessage(ctx, req.RoomID, req.Message)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SendNotificationRequest for sending school notifications
type SendNotificationRequest struct {
RoomID string `json:"room_id" binding:"required"`
Type string `json:"type" binding:"required"` // "absence", "grade", "announcement"
StudentName string `json:"student_name,omitempty"`
Date string `json:"date,omitempty"`
Lesson int `json:"lesson,omitempty"`
Subject string `json:"subject,omitempty"`
GradeType string `json:"grade_type,omitempty"`
Grade float64 `json:"grade,omitempty"`
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
TeacherName string `json:"teacher_name,omitempty"`
}
// SendNotification sends a typed notification (absence, grade, announcement)
func (h *CommunicationHandlers) SendNotification(c *gin.Context) {
if h.matrixService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"})
return
}
var req SendNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var err error
switch req.Type {
case "absence":
err = h.matrixService.SendAbsenceNotification(ctx, req.RoomID, req.StudentName, req.Date, req.Lesson)
case "grade":
err = h.matrixService.SendGradeNotification(ctx, req.RoomID, req.StudentName, req.Subject, req.GradeType, req.Grade)
case "announcement":
err = h.matrixService.SendClassAnnouncement(ctx, req.RoomID, req.Title, req.Content, req.TeacherName)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification type. Use: absence, grade, announcement"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// RegisterUserRequest for user registration
type RegisterUserRequest struct {
Username string `json:"username" binding:"required"`
DisplayName string `json:"display_name"`
}
// RegisterMatrixUser registers a new Matrix user
func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) {
if h.matrixService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"})
return
}
var req RegisterUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
resp, err := h.matrixService.RegisterUser(ctx, req.Username, req.DisplayName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"user_id": resp.UserID,
})
}
// ========================================
// Jitsi Video Conference Endpoints
// ========================================
// CreateMeetingRequest for creating Jitsi meetings
type CreateMeetingRequest struct {
Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class"
Title string `json:"title,omitempty"`
DisplayName string `json:"display_name"`
Email string `json:"email,omitempty"`
Duration int `json:"duration,omitempty"` // minutes
ClassName string `json:"class_name,omitempty"`
ParentName string `json:"parent_name,omitempty"`
StudentName string `json:"student_name,omitempty"`
Subject string `json:"subject,omitempty"`
StartTime time.Time `json:"start_time,omitempty"`
}
// CreateMeeting creates a new Jitsi meeting
func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req CreateMeetingRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
var link *jitsi.MeetingLink
var err error
switch req.Type {
case "quick":
link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName)
case "training":
link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration)
case "parent_teacher":
link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime)
case "class":
link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"room_name": link.RoomName,
"url": link.URL,
"join_url": link.JoinURL,
"moderator_url": link.ModeratorURL,
"password": link.Password,
"expires_at": link.ExpiresAt,
})
}
// GetEmbedURLRequest for embedding Jitsi
type GetEmbedURLRequest struct {
RoomName string `json:"room_name" binding:"required"`
DisplayName string `json:"display_name"`
AudioMuted bool `json:"audio_muted"`
VideoMuted bool `json:"video_muted"`
}
// GetEmbedURL returns an embeddable Jitsi URL
func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
var req GetEmbedURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config := &jitsi.MeetingConfig{
StartWithAudioMuted: req.AudioMuted,
StartWithVideoMuted: req.VideoMuted,
DisableDeepLinking: true,
}
embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config)
iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600)
c.JSON(http.StatusOK, gin.H{
"embed_url": embedURL,
"iframe_code": iframeCode,
})
}
// GetJitsiInfo returns Jitsi server information
func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) {
if h.jitsiService == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"})
return
}
info := h.jitsiService.GetServerInfo()
c.JSON(http.StatusOK, info)
}
// ========================================
// Admin Statistics Endpoints (for Admin Panel)
// ========================================
// CommunicationStats holds communication service statistics
type CommunicationStats struct {
Matrix MatrixStats `json:"matrix"`
Jitsi JitsiStats `json:"jitsi"`
}
// MatrixStats holds Matrix-specific statistics
type MatrixStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
ServerName string `json:"server_name"`
// TODO: Add real stats from Matrix Synapse Admin API
TotalUsers int `json:"total_users"`
TotalRooms int `json:"total_rooms"`
ActiveToday int `json:"active_today"`
MessagesToday int `json:"messages_today"`
}
// JitsiStats holds Jitsi-specific statistics
type JitsiStats struct {
Enabled bool `json:"enabled"`
Healthy bool `json:"healthy"`
BaseURL string `json:"base_url"`
AuthEnabled bool `json:"auth_enabled"`
// TODO: Add real stats from Jitsi SRTP API or Jicofo
ActiveMeetings int `json:"active_meetings"`
TotalParticipants int `json:"total_participants"`
MeetingsToday int `json:"meetings_today"`
AvgDurationMin int `json:"avg_duration_min"`
}
// GetAdminStats returns admin statistics for Matrix and Jitsi
func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) {
ctx := c.Request.Context()
stats := CommunicationStats{}
// Matrix Stats
if h.matrixService != nil {
matrixErr := h.matrixService.HealthCheck(ctx)
stats.Matrix = MatrixStats{
Enabled: true,
Healthy: matrixErr == nil,
ServerName: h.matrixService.GetServerName(),
// Placeholder stats - in production these would come from Synapse Admin API
TotalUsers: 0,
TotalRooms: 0,
ActiveToday: 0,
MessagesToday: 0,
}
} else {
stats.Matrix = MatrixStats{Enabled: false}
}
// Jitsi Stats
if h.jitsiService != nil {
jitsiErr := h.jitsiService.HealthCheck(ctx)
serverInfo := h.jitsiService.GetServerInfo()
stats.Jitsi = JitsiStats{
Enabled: true,
Healthy: jitsiErr == nil,
BaseURL: serverInfo["base_url"],
AuthEnabled: serverInfo["auth_enabled"] == "true",
// Placeholder stats - in production these would come from Jicofo/JVB stats
ActiveMeetings: 0,
TotalParticipants: 0,
MeetingsToday: 0,
AvgDurationMin: 0,
}
} else {
stats.Jitsi = JitsiStats{Enabled: false}
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Helper Functions
// ========================================
func errToString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// RegisterRoutes registers all communication routes
func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) {
comm := router.Group("/communication")
{
// Public health check
comm.GET("/status", h.GetCommunicationStatus)
// Protected routes
protected := comm.Group("")
protected.Use(authMiddleware)
{
// Matrix
protected.POST("/rooms", h.CreateRoom)
protected.POST("/rooms/invite", h.InviteUser)
protected.POST("/messages", h.SendMessage)
protected.POST("/notifications", h.SendNotification)
// Jitsi
protected.POST("/meetings", h.CreateMeeting)
protected.POST("/meetings/embed", h.GetEmbedURL)
protected.GET("/jitsi/info", h.GetJitsiInfo)
}
// Admin routes (for Matrix user registration and stats)
admin := comm.Group("/admin")
admin.Use(authMiddleware)
// TODO: Add AdminOnly middleware
{
admin.POST("/matrix/users", h.RegisterMatrixUser)
admin.GET("/stats", h.GetAdminStats)
}
}
}

View File

@@ -0,0 +1,407 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// TestGetCommunicationStatus_NoServices tests status with no services configured
func TestGetCommunicationStatus_NoServices_ReturnsDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
// Create handler with no services
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.GET("/api/v1/communication/status", handler.GetCommunicationStatus)
req, _ := http.NewRequest("GET", "/api/v1/communication/status", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
// Check matrix is disabled
matrix, ok := response["matrix"].(map[string]interface{})
if !ok {
t.Fatal("Expected matrix in response")
}
if matrix["enabled"] != false {
t.Error("Expected matrix.enabled to be false")
}
// Check jitsi is disabled
jitsi, ok := response["jitsi"].(map[string]interface{})
if !ok {
t.Fatal("Expected jitsi in response")
}
if jitsi["enabled"] != false {
t.Error("Expected jitsi.enabled to be false")
}
// Check timestamp exists
if _, ok := response["timestamp"]; !ok {
t.Error("Expected timestamp in response")
}
}
// TestCreateRoom_NoMatrixService tests room creation without Matrix
func TestCreateRoom_NoMatrixService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/rooms", handler.CreateRoom)
body := `{"type": "class_info", "class_name": "5b"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
var response map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response["error"] != "Matrix service not configured" {
t.Errorf("Unexpected error message: %s", response["error"])
}
}
// TestCreateRoom_InvalidBody tests room creation with invalid body
func TestCreateRoom_InvalidBody_Returns400(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/rooms", handler.CreateRoom)
req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString("{invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Service unavailable check happens first, so we get 503
// This is expected behavior - service check before body validation
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestInviteUser_NoMatrixService tests invite without Matrix
func TestInviteUser_NoMatrixService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/rooms/invite", handler.InviteUser)
body := `{"room_id": "!abc:server", "user_id": "@user:server"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/rooms/invite", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestSendMessage_NoMatrixService tests message sending without Matrix
func TestSendMessage_NoMatrixService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/messages", handler.SendMessage)
body := `{"room_id": "!abc:server", "message": "Hello"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/messages", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestSendNotification_NoMatrixService tests notification without Matrix
func TestSendNotification_NoMatrixService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/notifications", handler.SendNotification)
body := `{"room_id": "!abc:server", "type": "absence", "student_name": "Max"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/notifications", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestCreateMeeting_NoJitsiService tests meeting creation without Jitsi
func TestCreateMeeting_NoJitsiService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/meetings", handler.CreateMeeting)
body := `{"type": "quick", "display_name": "Teacher"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/meetings", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
var response map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response["error"] != "Jitsi service not configured" {
t.Errorf("Unexpected error message: %s", response["error"])
}
}
// TestGetEmbedURL_NoJitsiService tests embed URL without Jitsi
func TestGetEmbedURL_NoJitsiService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/meetings/embed", handler.GetEmbedURL)
body := `{"room_name": "test-room", "display_name": "User"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/meetings/embed", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestGetJitsiInfo_NoJitsiService tests Jitsi info without service
func TestGetJitsiInfo_NoJitsiService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.GET("/api/v1/communication/jitsi/info", handler.GetJitsiInfo)
req, _ := http.NewRequest("GET", "/api/v1/communication/jitsi/info", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestRegisterMatrixUser_NoMatrixService tests user registration without Matrix
func TestRegisterMatrixUser_NoMatrixService_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/admin/matrix/users", handler.RegisterMatrixUser)
body := `{"username": "testuser", "display_name": "Test User"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/admin/matrix/users", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestGetAdminStats_NoServices tests admin stats without services
func TestGetAdminStats_NoServices_ReturnsDisabledStats(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.GET("/api/v1/communication/admin/stats", handler.GetAdminStats)
req, _ := http.NewRequest("GET", "/api/v1/communication/admin/stats", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response CommunicationStats
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to parse response: %v", err)
}
if response.Matrix.Enabled {
t.Error("Expected matrix.enabled to be false")
}
if response.Jitsi.Enabled {
t.Error("Expected jitsi.enabled to be false")
}
}
// TestErrToString tests the helper function
func TestErrToString_NilError_ReturnsEmpty(t *testing.T) {
result := errToString(nil)
if result != "" {
t.Errorf("Expected empty string, got %s", result)
}
}
// TestErrToString_WithError_ReturnsMessage tests error string conversion
func TestErrToString_WithError_ReturnsMessage(t *testing.T) {
err := &testError{"test error message"}
result := errToString(err)
if result != "test error message" {
t.Errorf("Expected 'test error message', got %s", result)
}
}
// testError is a simple error implementation for testing
type testError struct {
message string
}
func (e *testError) Error() string {
return e.message
}
// TestCreateRoomRequest_Types tests different room types validation
func TestCreateRoom_InvalidType_Returns400(t *testing.T) {
gin.SetMode(gin.TestMode)
// Since we don't have Matrix service, we get 503 first
// This test documents expected behavior when Matrix IS available
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/rooms", handler.CreateRoom)
body := `{"type": "invalid_type", "class_name": "5b"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Without Matrix service, we get 503 before type validation
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestCreateMeeting_InvalidType tests invalid meeting type
func TestCreateMeeting_InvalidType_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/meetings", handler.CreateMeeting)
body := `{"type": "invalid", "display_name": "User"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/meetings", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Without Jitsi service, we get 503 before type validation
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestSendNotification_InvalidType tests invalid notification type
func TestSendNotification_InvalidType_Returns503(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewCommunicationHandlers(nil, nil)
router := gin.New()
router.POST("/api/v1/communication/notifications", handler.SendNotification)
body := `{"room_id": "!abc:server", "type": "invalid", "student_name": "Max"}`
req, _ := http.NewRequest("POST", "/api/v1/communication/notifications", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
// Without Matrix service, we get 503 before type validation
if w.Code != http.StatusServiceUnavailable {
t.Errorf("Expected status 503, got %d", w.Code)
}
}
// TestNewCommunicationHandlers tests constructor
func TestNewCommunicationHandlers_WithNilServices_CreatesHandler(t *testing.T) {
handler := NewCommunicationHandlers(nil, nil)
if handler == nil {
t.Fatal("Expected handler to be created")
}
if handler.matrixService != nil {
t.Error("Expected matrixService to be nil")
}
if handler.jitsiService != nil {
t.Error("Expected jitsiService to be nil")
}
}

View File

@@ -0,0 +1,92 @@
package handlers
import (
"net/http"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// DeadlineHandler handles deadline-related requests
type DeadlineHandler struct {
deadlineService *services.DeadlineService
}
// NewDeadlineHandler creates a new deadline handler
func NewDeadlineHandler(deadlineService *services.DeadlineService) *DeadlineHandler {
return &DeadlineHandler{
deadlineService: deadlineService,
}
}
// GetPendingDeadlines returns all pending consent deadlines for the current user
func (h *DeadlineHandler) GetPendingDeadlines(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
deadlines, err := h.deadlineService.GetPendingDeadlines(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch deadlines"})
return
}
c.JSON(http.StatusOK, gin.H{
"deadlines": deadlines,
"count": len(deadlines),
})
}
// GetSuspensionStatus returns the current suspension status for a user
func (h *DeadlineHandler) GetSuspensionStatus(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
suspended, err := h.deadlineService.IsUserSuspended(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check suspension status"})
return
}
response := gin.H{
"suspended": suspended,
}
if suspended {
suspension, err := h.deadlineService.GetAccountSuspension(c.Request.Context(), userID)
if err == nil && suspension != nil {
response["reason"] = suspension.Reason
response["suspended_at"] = suspension.SuspendedAt
response["details"] = suspension.Details
}
deadlines, err := h.deadlineService.GetPendingDeadlines(c.Request.Context(), userID)
if err == nil {
response["pending_deadlines"] = deadlines
}
}
c.JSON(http.StatusOK, response)
}
// TriggerDeadlineProcessing manually triggers deadline processing (admin only)
func (h *DeadlineHandler) TriggerDeadlineProcessing(c *gin.Context) {
if !middleware.IsAdmin(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
return
}
if err := h.deadlineService.ProcessDailyDeadlines(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
}

View File

@@ -0,0 +1,948 @@
package handlers
import (
"context"
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// DSRHandler handles Data Subject Request HTTP endpoints
type DSRHandler struct {
dsrService *services.DSRService
}
// NewDSRHandler creates a new DSR handler
func NewDSRHandler(dsrService *services.DSRService) *DSRHandler {
return &DSRHandler{
dsrService: dsrService,
}
}
// ========================================
// USER ENDPOINTS
// ========================================
// CreateDSR creates a new data subject request (user-facing)
func (h *DSRHandler) CreateDSR(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
// Get user email if not provided
if req.RequesterEmail == "" {
var email string
ctx := context.Background()
h.dsrService.GetPool().QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email)
req.RequesterEmail = email
}
// Set source as API
req.Source = "api"
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Ihre Anfrage wurde erfolgreich eingereicht",
"request_number": dsr.RequestNumber,
"dsr": dsr,
})
}
// GetMyDSRs returns DSRs for the current user
func (h *DSRHandler) GetMyDSRs(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
dsrs, err := h.dsrService.ListByUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
return
}
c.JSON(http.StatusOK, gin.H{"requests": dsrs})
}
// GetMyDSR returns a specific DSR for the current user
func (h *DSRHandler) GetMyDSR(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
return
}
// Verify ownership
if dsr.UserID == nil || *dsr.UserID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
return
}
c.JSON(http.StatusOK, dsr)
}
// CancelMyDSR cancels a user's own DSR
func (h *DSRHandler) CancelMyDSR(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
err = h.dsrService.CancelRequest(c.Request.Context(), dsrID, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"})
}
// ========================================
// ADMIN ENDPOINTS
// ========================================
// AdminListDSR returns all DSRs with filters (admin only)
func (h *DSRHandler) AdminListDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
// Parse pagination
limit := 20
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
// Parse filters
filters := models.DSRListFilters{}
if status := c.Query("status"); status != "" {
filters.Status = &status
}
if reqType := c.Query("request_type"); reqType != "" {
filters.RequestType = &reqType
}
if assignedTo := c.Query("assigned_to"); assignedTo != "" {
filters.AssignedTo = &assignedTo
}
if priority := c.Query("priority"); priority != "" {
filters.Priority = &priority
}
if c.Query("overdue_only") == "true" {
filters.OverdueOnly = true
}
if search := c.Query("search"); search != "" {
filters.Search = &search
}
if from := c.Query("from_date"); from != "" {
if t, err := time.Parse("2006-01-02", from); err == nil {
filters.FromDate = &t
}
}
if to := c.Query("to_date"); to != "" {
if t, err := time.Parse("2006-01-02", to); err == nil {
filters.ToDate = &t
}
}
dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"})
return
}
c.JSON(http.StatusOK, gin.H{
"requests": dsrs,
"total": total,
"limit": limit,
"offset": offset,
})
}
// AdminGetDSR returns a specific DSR (admin only)
func (h *DSRHandler) AdminGetDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"})
return
}
c.JSON(http.StatusOK, dsr)
}
// AdminCreateDSR creates a DSR manually (admin only)
func (h *DSRHandler) AdminCreateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
// Set source as admin_panel
if req.Source == "" {
req.Source = "admin_panel"
}
dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Anfrage wurde erstellt",
"request_number": dsr.RequestNumber,
"dsr": dsr,
})
}
// AdminUpdateDSR updates a DSR (admin only)
func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.UpdateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := c.Request.Context()
// Update status if provided
if req.Status != nil {
err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
// Update processing notes
if req.ProcessingNotes != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2
`, *req.ProcessingNotes, dsrID)
}
// Update priority
if req.Priority != nil {
h.dsrService.GetPool().Exec(ctx, `
UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2
`, *req.Priority, dsrID)
}
// Get updated DSR
dsr, _ := h.dsrService.GetByID(ctx, dsrID)
c.JSON(http.StatusOK, gin.H{
"message": "Anfrage wurde aktualisiert",
"dsr": dsr,
})
}
// AdminGetDSRStats returns dashboard statistics
func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
stats, err := h.dsrService.GetDashboardStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"})
return
}
c.JSON(http.StatusOK, stats)
}
// AdminVerifyIdentity verifies the identity of a requester
func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.VerifyDSRIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"})
}
// AdminAssignDSR assigns a DSR to a user
func (h *DSRHandler) AdminAssignDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
AssigneeID string `json:"assignee_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
assigneeID, err := uuid.Parse(req.AssigneeID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"})
return
}
err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"})
}
// AdminExtendDSRDeadline extends the deadline for a DSR
func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.ExtendDSRDeadlineRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"})
}
// AdminCompleteDSR marks a DSR as completed
func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.CompleteDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"})
}
// AdminRejectDSR rejects a DSR
func (h *DSRHandler) AdminRejectDSR(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.RejectDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"})
}
// AdminGetDSRHistory returns the status history for a DSR
func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"})
return
}
c.JSON(http.StatusOK, gin.H{"history": history})
}
// AdminGetDSRCommunications returns communications for a DSR
func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"})
return
}
c.JSON(http.StatusOK, gin.H{"communications": comms})
}
// AdminSendDSRCommunication sends a communication for a DSR
func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req models.SendDSRCommunicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"})
}
// AdminUpdateDSRStatus updates the status of a DSR
func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Status string `json:"status" binding:"required"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"})
}
// ========================================
// EXCEPTION CHECKS (Art. 17)
// ========================================
// AdminGetExceptionChecks returns exception checks for an erasure DSR
func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"exception_checks": checks})
}
// AdminInitExceptionChecks initializes exception checks for an erasure DSR
func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
dsrID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"})
return
}
err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"})
}
// AdminUpdateExceptionCheck updates a single exception check
func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
checkID, err := uuid.Parse(c.Param("checkId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Applies bool `json:"applies"`
Notes *string `json:"notes"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"})
}
// ========================================
// TEMPLATE ENDPOINTS
// ========================================
// AdminGetDSRTemplates returns all DSR templates
func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at
FROM dsr_templates ORDER BY sort_order, name
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var id uuid.UUID
var templateType, name string
var description *string
var requestTypes []byte
var isActive bool
var sortOrder int
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"id": id,
"template_type": templateType,
"name": name,
"description": description,
"request_types": string(requestTypes),
"is_active": isActive,
"sort_order": sortOrder,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// AdminGetDSRTemplateVersions returns versions for a template
func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
ctx := c.Request.Context()
rows, err := h.dsrService.GetPool().Query(ctx, `
SELECT id, template_id, version, language, subject, body_html, body_text,
status, published_at, created_by, approved_by, approved_at, created_at, updated_at
FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC
`, templateID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
return
}
defer rows.Close()
var versions []map[string]interface{}
for rows.Next() {
var id, tempID uuid.UUID
var version, language, subject, bodyHTML, bodyText, status string
var publishedAt, approvedAt *time.Time
var createdBy, approvedBy *uuid.UUID
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText,
&status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt)
if err != nil {
continue
}
versions = append(versions, map[string]interface{}{
"id": id,
"template_id": tempID,
"version": version,
"language": language,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
"status": status,
"published_at": publishedAt,
"created_by": createdBy,
"approved_by": approvedBy,
"approved_at": approvedAt,
"created_at": createdAt,
"updated_at": updatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"versions": versions})
}
// AdminCreateDSRTemplateVersion creates a new template version
func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
templateID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
return
}
userID, _ := middleware.GetUserID(c)
var req struct {
Version string `json:"version" binding:"required"`
Language string `json:"language"`
Subject string `json:"subject" binding:"required"`
BodyHTML string `json:"body_html" binding:"required"`
BodyText string `json:"body_text"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Language == "" {
req.Language = "de"
}
ctx := c.Request.Context()
var versionID uuid.UUID
err = h.dsrService.GetPool().QueryRow(ctx, `
INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Version wurde erstellt",
"id": versionID,
})
}
// AdminPublishDSRTemplateVersion publishes a template version
func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
versionID, err := uuid.Parse(c.Param("versionId"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
return
}
userID, _ := middleware.GetUserID(c)
ctx := c.Request.Context()
_, err = h.dsrService.GetPool().Exec(ctx, `
UPDATE dsr_template_versions
SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW()
WHERE id = $2
`, userID, versionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"})
}
// AdminGetPublishedDSRTemplates returns all published templates for selection
func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) {
if !middleware.IsAdmin(c) && !middleware.IsDSB(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"})
return
}
requestType := c.Query("request_type")
language := c.DefaultQuery("language", "de")
ctx := c.Request.Context()
query := `
SELECT t.id, t.template_type, t.name, t.description,
v.id as version_id, v.version, v.subject, v.body_html, v.body_text
FROM dsr_templates t
JOIN dsr_template_versions v ON t.id = v.template_id
WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1
`
args := []interface{}{language}
if requestType != "" {
query += ` AND t.request_types @> $2::jsonb`
args = append(args, `["`+requestType+`"]`)
}
query += " ORDER BY t.sort_order, t.name"
rows, err := h.dsrService.GetPool().Query(ctx, query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
return
}
defer rows.Close()
var templates []map[string]interface{}
for rows.Next() {
var templateID, versionID uuid.UUID
var templateType, name, version, subject, bodyHTML, bodyText string
var description *string
err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText)
if err != nil {
continue
}
templates = append(templates, map[string]interface{}{
"template_id": templateID,
"template_type": templateType,
"name": name,
"description": description,
"version_id": versionID,
"version": version,
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// ========================================
// DEADLINE PROCESSING
// ========================================
// ProcessDeadlines triggers deadline checking (called by scheduler)
func (h *DSRHandler) ProcessDeadlines(c *gin.Context) {
err := h.dsrService.ProcessDeadlines(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"})
}

View File

@@ -0,0 +1,448 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/breakpilot/consent-service/internal/models"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
// TestCreateDSR_InvalidBody tests create DSR with invalid body
func TestCreateDSR_InvalidBody_Returns400(t *testing.T) {
router := gin.New()
// Mock handler that mimics the actual behavior for invalid body
router.POST("/api/v1/dsr", func(c *gin.Context) {
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()})
return
}
})
// Invalid JSON
req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString("{invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestCreateDSR_MissingType tests create DSR with missing type
func TestCreateDSR_MissingType_Returns400(t *testing.T) {
router := gin.New()
router.POST("/api/v1/dsr", func(c *gin.Context) {
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.RequestType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "request_type is required"})
return
}
})
body := `{"requester_email": "test@example.com"}`
req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestCreateDSR_InvalidType tests create DSR with invalid type
func TestCreateDSR_InvalidType_Returns400(t *testing.T) {
router := gin.New()
router.POST("/api/v1/dsr", func(c *gin.Context) {
var req models.CreateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if !models.IsValidDSRRequestType(req.RequestType) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request_type"})
return
}
})
body := `{"request_type": "invalid_type", "requester_email": "test@example.com"}`
req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestAdminListDSR_Unauthorized_Returns401 tests admin list without auth
func TestAdminListDSR_Unauthorized_Returns401(t *testing.T) {
router := gin.New()
// Simplified auth check
router.GET("/api/v1/admin/dsr", func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
c.JSON(http.StatusOK, gin.H{"requests": []interface{}{}})
})
req, _ := http.NewRequest("GET", "/api/v1/admin/dsr", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status 401, got %d", w.Code)
}
}
// TestAdminListDSR_ValidRequest tests admin list with valid auth
func TestAdminListDSR_ValidRequest_Returns200(t *testing.T) {
router := gin.New()
router.GET("/api/v1/admin/dsr", func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
c.JSON(http.StatusOK, gin.H{
"requests": []interface{}{},
"total": 0,
"limit": 20,
"offset": 0,
})
})
req, _ := http.NewRequest("GET", "/api/v1/admin/dsr", nil)
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
if _, ok := response["requests"]; !ok {
t.Error("Response should contain 'requests' field")
}
if _, ok := response["total"]; !ok {
t.Error("Response should contain 'total' field")
}
}
// TestAdminGetDSRStats_ValidRequest tests admin stats endpoint
func TestAdminGetDSRStats_ValidRequest_Returns200(t *testing.T) {
router := gin.New()
router.GET("/api/v1/admin/dsr/stats", func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
c.JSON(http.StatusOK, gin.H{
"total_requests": 0,
"pending_requests": 0,
"overdue_requests": 0,
"completed_this_month": 0,
"average_processing_days": 0,
"by_type": map[string]int{},
"by_status": map[string]int{},
})
})
req, _ := http.NewRequest("GET", "/api/v1/admin/dsr/stats", nil)
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
expectedFields := []string{"total_requests", "pending_requests", "overdue_requests", "by_type", "by_status"}
for _, field := range expectedFields {
if _, ok := response[field]; !ok {
t.Errorf("Response should contain '%s' field", field)
}
}
}
// TestAdminUpdateDSR_InvalidStatus_Returns400 tests admin update with invalid status
func TestAdminUpdateDSR_InvalidStatus_Returns400(t *testing.T) {
router := gin.New()
router.PUT("/api/v1/admin/dsr/:id", func(c *gin.Context) {
var req models.UpdateDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Status != nil && !models.IsValidDSRStatus(*req.Status) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Updated"})
})
body := `{"status": "invalid_status"}`
req, _ := http.NewRequest("PUT", "/api/v1/admin/dsr/123", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestAdminVerifyIdentity_ValidRequest_Returns200 tests identity verification
func TestAdminVerifyIdentity_ValidRequest_Returns200(t *testing.T) {
router := gin.New()
router.POST("/api/v1/admin/dsr/:id/verify-identity", func(c *gin.Context) {
var req models.VerifyDSRIdentityRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Method == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "method is required"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Identität verifiziert"})
})
body := `{"method": "id_card"}`
req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/verify-identity", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
// TestAdminExtendDeadline_MissingReason_Returns400 tests extend deadline without reason
func TestAdminExtendDeadline_MissingReason_Returns400(t *testing.T) {
router := gin.New()
router.POST("/api/v1/admin/dsr/:id/extend", func(c *gin.Context) {
var req models.ExtendDSRDeadlineRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.Reason == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Deadline extended"})
})
body := `{"days": 30}`
req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/extend", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestAdminCompleteDSR_ValidRequest_Returns200 tests complete DSR
func TestAdminCompleteDSR_ValidRequest_Returns200(t *testing.T) {
router := gin.New()
router.POST("/api/v1/admin/dsr/:id/complete", func(c *gin.Context) {
var req models.CompleteDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage erfolgreich abgeschlossen"})
})
body := `{"result_summary": "Alle Daten wurden bereitgestellt"}`
req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/complete", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
// TestAdminRejectDSR_MissingLegalBasis_Returns400 tests reject DSR without legal basis
func TestAdminRejectDSR_MissingLegalBasis_Returns400(t *testing.T) {
router := gin.New()
router.POST("/api/v1/admin/dsr/:id/reject", func(c *gin.Context) {
var req models.RejectDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.LegalBasis == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "legal_basis is required"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Rejected"})
})
body := `{"reason": "Some reason"}`
req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/reject", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status 400, got %d", w.Code)
}
}
// TestAdminRejectDSR_ValidRequest_Returns200 tests reject DSR with valid data
func TestAdminRejectDSR_ValidRequest_Returns200(t *testing.T) {
router := gin.New()
router.POST("/api/v1/admin/dsr/:id/reject", func(c *gin.Context) {
var req models.RejectDSRRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
if req.LegalBasis == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "legal_basis is required"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Anfrage abgelehnt"})
})
body := `{"reason": "Daten benötigt für Rechtsstreit", "legal_basis": "Art. 17(3)e"}`
req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/reject", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
}
// TestGetDSRTemplates_Returns200 tests templates endpoint
func TestGetDSRTemplates_Returns200(t *testing.T) {
router := gin.New()
router.GET("/api/v1/admin/dsr-templates", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"templates": []map[string]interface{}{
{
"id": "uuid-1",
"template_type": "dsr_receipt_access",
"name": "Eingangsbestätigung (Art. 15)",
},
},
})
})
req, _ := http.NewRequest("GET", "/api/v1/admin/dsr-templates", nil)
req.Header.Set("Authorization", "Bearer test-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
if _, ok := response["templates"]; !ok {
t.Error("Response should contain 'templates' field")
}
}
// TestRequestTypeValidation tests all valid request types
func TestRequestTypeValidation(t *testing.T) {
validTypes := []string{"access", "rectification", "erasure", "restriction", "portability"}
for _, reqType := range validTypes {
if !models.IsValidDSRRequestType(reqType) {
t.Errorf("Expected %s to be a valid request type", reqType)
}
}
invalidTypes := []string{"invalid", "delete", "copy", ""}
for _, reqType := range invalidTypes {
if models.IsValidDSRRequestType(reqType) {
t.Errorf("Expected %s to be an invalid request type", reqType)
}
}
}
// TestStatusValidation tests all valid statuses
func TestStatusValidation(t *testing.T) {
validStatuses := []string{"intake", "identity_verification", "processing", "completed", "rejected", "cancelled"}
for _, status := range validStatuses {
if !models.IsValidDSRStatus(status) {
t.Errorf("Expected %s to be a valid status", status)
}
}
invalidStatuses := []string{"invalid", "pending", "done", ""}
for _, status := range invalidStatuses {
if models.IsValidDSRStatus(status) {
t.Errorf("Expected %s to be an invalid status", status)
}
}
}

View File

@@ -0,0 +1,528 @@
package handlers
import (
"net/http"
"strconv"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// EmailTemplateHandler handles email template operations
type EmailTemplateHandler struct {
service *services.EmailTemplateService
}
// NewEmailTemplateHandler creates a new email template handler
func NewEmailTemplateHandler(service *services.EmailTemplateService) *EmailTemplateHandler {
return &EmailTemplateHandler{service: service}
}
// GetAllTemplateTypes returns all available email template types with their variables
// GET /api/v1/admin/email-templates/types
func (h *EmailTemplateHandler) GetAllTemplateTypes(c *gin.Context) {
types := h.service.GetAllTemplateTypes()
c.JSON(http.StatusOK, gin.H{"types": types})
}
// GetAllTemplates returns all email templates with their latest published versions
// GET /api/v1/admin/email-templates
func (h *EmailTemplateHandler) GetAllTemplates(c *gin.Context) {
templates, err := h.service.GetAllTemplates(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"templates": templates})
}
// GetTemplate returns a single template by ID
// GET /api/v1/admin/email-templates/:id
func (h *EmailTemplateHandler) GetTemplate(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template ID"})
return
}
template, err := h.service.GetTemplateByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
c.JSON(http.StatusOK, template)
}
// CreateTemplate creates a new email template type
// POST /api/v1/admin/email-templates
func (h *EmailTemplateHandler) CreateTemplate(c *gin.Context) {
var req models.CreateEmailTemplateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
template, err := h.service.CreateEmailTemplate(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, template)
}
// GetTemplateVersions returns all versions for a template
// GET /api/v1/admin/email-templates/:id/versions
func (h *EmailTemplateHandler) GetTemplateVersions(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template ID"})
return
}
versions, err := h.service.GetVersionsByTemplateID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"versions": versions})
}
// GetVersion returns a single version by ID
// GET /api/v1/admin/email-template-versions/:id
func (h *EmailTemplateHandler) GetVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
c.JSON(http.StatusOK, version)
}
// CreateVersion creates a new version of an email template
// POST /api/v1/admin/email-template-versions
func (h *EmailTemplateHandler) CreateVersion(c *gin.Context) {
var req models.CreateEmailTemplateVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get user ID from context
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
uid, _ := uuid.Parse(userID.(string))
version, err := h.service.CreateTemplateVersion(c.Request.Context(), &req, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, version)
}
// UpdateVersion updates a version
// PUT /api/v1/admin/email-template-versions/:id
func (h *EmailTemplateHandler) UpdateVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req models.UpdateEmailTemplateVersionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.UpdateVersion(c.Request.Context(), id, &req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version updated"})
}
// SubmitForReview submits a version for review
// POST /api/v1/admin/email-template-versions/:id/submit
func (h *EmailTemplateHandler) SubmitForReview(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req struct {
Comment *string `json:"comment"`
}
c.ShouldBindJSON(&req)
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
uid, _ := uuid.Parse(userID.(string))
if err := h.service.SubmitForReview(c.Request.Context(), id, uid, req.Comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version submitted for review"})
}
// ApproveVersion approves a version (DSB only)
// POST /api/v1/admin/email-template-versions/:id/approve
func (h *EmailTemplateHandler) ApproveVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
// Check role
role, exists := c.Get("user_role")
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
var req struct {
Comment *string `json:"comment"`
ScheduledPublishAt *string `json:"scheduled_publish_at"`
}
c.ShouldBindJSON(&req)
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
var scheduledAt *time.Time
if req.ScheduledPublishAt != nil {
t, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
if err == nil {
scheduledAt = &t
}
}
if err := h.service.ApproveVersion(c.Request.Context(), id, uid, req.Comment, scheduledAt); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version approved"})
}
// RejectVersion rejects a version
// POST /api/v1/admin/email-template-versions/:id/reject
func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
role, exists := c.Get("user_role")
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
var req struct {
Comment string `json:"comment" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "comment is required"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.RejectVersion(c.Request.Context(), id, uid, req.Comment); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version rejected"})
}
// PublishVersion publishes an approved version
// POST /api/v1/admin/email-template-versions/:id/publish
func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
role, exists := c.Get("user_role")
if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "version published"})
}
// GetApprovals returns approval history for a version
// GET /api/v1/admin/email-template-versions/:id/approvals
func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
approvals, err := h.service.GetApprovals(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"approvals": approvals})
}
// PreviewVersion renders a preview of an email template version
// POST /api/v1/admin/email-template-versions/:id/preview
func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req struct {
Variables map[string]string `json:"variables"`
}
c.ShouldBindJSON(&req)
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Use default test values if not provided
if req.Variables == nil {
req.Variables = map[string]string{
"user_name": "Max Mustermann",
"user_email": "max@example.com",
"login_url": "https://breakpilot.app/login",
"support_email": "support@breakpilot.app",
"verification_url": "https://breakpilot.app/verify?token=abc123",
"verification_code": "123456",
"expires_in": "24 Stunden",
"reset_url": "https://breakpilot.app/reset?token=xyz789",
"reset_code": "RESET123",
"ip_address": "192.168.1.1",
"device_info": "Chrome auf Windows 11",
"changed_at": time.Now().Format("02.01.2006 15:04"),
"enabled_at": time.Now().Format("02.01.2006 15:04"),
"disabled_at": time.Now().Format("02.01.2006 15:04"),
"support_url": "https://breakpilot.app/support",
"security_url": "https://breakpilot.app/account/security",
"login_time": time.Now().Format("02.01.2006 15:04"),
"location": "Berlin, Deutschland",
"activity_type": "Mehrere fehlgeschlagene Login-Versuche",
"activity_time": time.Now().Format("02.01.2006 15:04"),
"locked_at": time.Now().Format("02.01.2006 15:04"),
"reason": "Zu viele fehlgeschlagene Login-Versuche",
"unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"),
"unlocked_at": time.Now().Format("02.01.2006 15:04"),
"requested_at": time.Now().Format("02.01.2006"),
"deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"),
"cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123",
"data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs",
"deleted_at": time.Now().Format("02.01.2006"),
"feedback_url": "https://breakpilot.app/feedback",
"download_url": "https://breakpilot.app/export/download?token=export123",
"file_size": "2.3 MB",
"old_email": "alt@example.com",
"new_email": "neu@example.com",
"document_name": "Datenschutzerklärung",
"document_type": "privacy",
"version": "2.0.0",
"consent_url": "https://breakpilot.app/consent",
"deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"),
"days_left": "7",
"hours_left": "24 Stunden",
"consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.",
"suspended_at": time.Now().Format("02.01.2006 15:04"),
"documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0",
}
}
preview, err := h.service.RenderTemplate(version, req.Variables)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, preview)
}
// SendTestEmail sends a test email
// POST /api/v1/admin/email-template-versions/:id/send-test
func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"})
return
}
var req models.SendTestEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.VersionID = idStr
version, err := h.service.GetVersionByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return
}
// Get template to find type
template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "template not found"})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
// Send test email
if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "test email sent"})
}
// GetSettings returns global email settings
// GET /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) GetSettings(c *gin.Context) {
settings, err := h.service.GetSettings(c.Request.Context())
if err != nil {
// Return default settings if none exist
c.JSON(http.StatusOK, gin.H{
"company_name": "BreakPilot",
"sender_name": "BreakPilot",
"sender_email": "noreply@breakpilot.app",
"primary_color": "#2563eb",
"secondary_color": "#64748b",
})
return
}
c.JSON(http.StatusOK, settings)
}
// UpdateSettings updates global email settings
// PUT /api/v1/admin/email-templates/settings
func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) {
var req models.UpdateEmailTemplateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID, _ := c.Get("user_id")
uid, _ := uuid.Parse(userID.(string))
if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "settings updated"})
}
// GetEmailStats returns email statistics
// GET /api/v1/admin/email-templates/stats
func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) {
stats, err := h.service.GetEmailStats(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// GetSendLogs returns email send logs
// GET /api/v1/admin/email-templates/logs
func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "50")
offsetStr := c.DefaultQuery("offset", "0")
limit, _ := strconv.Atoi(limitStr)
offset, _ := strconv.Atoi(offsetStr)
if limit > 100 {
limit = 100
}
logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
}
// GetDefaultContent returns default template content for a type
// GET /api/v1/admin/email-templates/default/:type
func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) {
templateType := c.Param("type")
language := c.DefaultQuery("language", "de")
subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language)
c.JSON(http.StatusOK, gin.H{
"subject": subject,
"body_html": bodyHTML,
"body_text": bodyText,
})
}
// InitializeTemplates initializes default email templates
// POST /api/v1/admin/email-templates/initialize
func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) {
role, exists := c.Get("user_role")
if !exists || (role != "admin" && role != "super_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
return
}
if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,805 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
// setupTestRouter creates a test router with handlers
// Note: For full integration tests, use a test database
func setupTestRouter() *gin.Engine {
router := gin.New()
return router
}
// TestHealthEndpoint tests the health check endpoint
func TestHealthEndpoint(t *testing.T) {
router := setupTestRouter()
// Add health endpoint
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "healthy",
"service": "consent-service",
"version": "1.0.0",
})
})
req, _ := http.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
if response["status"] != "healthy" {
t.Errorf("Expected status 'healthy', got %v", response["status"])
}
}
// TestUnauthorizedAccess tests that protected endpoints require auth
func TestUnauthorizedAccess(t *testing.T) {
router := setupTestRouter()
// Add a protected endpoint
router.GET("/api/v1/consent/my", func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"})
return
}
c.JSON(http.StatusOK, gin.H{"consents": []interface{}{}})
})
tests := []struct {
name string
authorization string
expectedStatus int
}{
{"no auth header", "", http.StatusUnauthorized},
{"empty bearer", "Bearer ", http.StatusOK}, // Would be invalid in real middleware
{"valid format", "Bearer test-token", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "/api/v1/consent/my", nil)
if tt.authorization != "" {
req.Header.Set("Authorization", tt.authorization)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// TestCreateConsentRequest tests consent creation request validation
func TestCreateConsentRequest(t *testing.T) {
type ConsentRequest struct {
DocumentType string `json:"document_type"`
VersionID string `json:"version_id"`
Consented bool `json:"consented"`
}
tests := []struct {
name string
request ConsentRequest
expectValid bool
}{
{
name: "valid consent",
request: ConsentRequest{
DocumentType: "terms",
VersionID: "123e4567-e89b-12d3-a456-426614174000",
Consented: true,
},
expectValid: true,
},
{
name: "missing document type",
request: ConsentRequest{
VersionID: "123e4567-e89b-12d3-a456-426614174000",
Consented: true,
},
expectValid: false,
},
{
name: "missing version ID",
request: ConsentRequest{
DocumentType: "terms",
Consented: true,
},
expectValid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isValid := tt.request.DocumentType != "" && tt.request.VersionID != ""
if isValid != tt.expectValid {
t.Errorf("Expected valid=%v, got %v", tt.expectValid, isValid)
}
})
}
}
// TestDocumentTypeValidation tests valid document types
func TestDocumentTypeValidation(t *testing.T) {
validTypes := map[string]bool{
"terms": true,
"privacy": true,
"cookies": true,
"community_guidelines": true,
"imprint": true,
}
tests := []struct {
docType string
expected bool
}{
{"terms", true},
{"privacy", true},
{"cookies", true},
{"community_guidelines", true},
{"imprint", true},
{"invalid", false},
{"", false},
{"Terms", false}, // case sensitive
}
for _, tt := range tests {
t.Run(tt.docType, func(t *testing.T) {
_, isValid := validTypes[tt.docType]
if isValid != tt.expected {
t.Errorf("Expected %s valid=%v, got %v", tt.docType, tt.expected, isValid)
}
})
}
}
// TestVersionStatusTransitions tests valid status transitions
func TestVersionStatusTransitions(t *testing.T) {
validTransitions := map[string][]string{
"draft": {"review"},
"review": {"approved", "rejected"},
"approved": {"scheduled", "published"},
"scheduled": {"published"},
"published": {"archived"},
"rejected": {"draft"},
"archived": {}, // terminal state
}
tests := []struct {
fromStatus string
toStatus string
expected bool
}{
{"draft", "review", true},
{"draft", "published", false},
{"review", "approved", true},
{"review", "rejected", true},
{"review", "published", false},
{"approved", "published", true},
{"approved", "scheduled", true},
{"published", "archived", true},
{"published", "draft", false},
{"archived", "draft", false},
}
for _, tt := range tests {
t.Run(tt.fromStatus+"->"+tt.toStatus, func(t *testing.T) {
allowed := false
if transitions, ok := validTransitions[tt.fromStatus]; ok {
for _, t := range transitions {
if t == tt.toStatus {
allowed = true
break
}
}
}
if allowed != tt.expected {
t.Errorf("Transition %s->%s: expected %v, got %v",
tt.fromStatus, tt.toStatus, tt.expected, allowed)
}
})
}
}
// TestRolePermissions tests role-based access control
func TestRolePermissions(t *testing.T) {
permissions := map[string]map[string]bool{
"user": {
"view_documents": true,
"give_consent": true,
"view_own_data": true,
"request_deletion": true,
"create_document": false,
"publish_version": false,
"approve_version": false,
},
"admin": {
"view_documents": true,
"give_consent": true,
"view_own_data": true,
"create_document": true,
"edit_version": true,
"publish_version": true,
"approve_version": false, // Only DSB
},
"data_protection_officer": {
"view_documents": true,
"create_document": true,
"edit_version": true,
"approve_version": true,
"publish_version": true,
"view_audit_log": true,
},
}
tests := []struct {
role string
action string
shouldHave bool
}{
{"user", "view_documents", true},
{"user", "create_document", false},
{"admin", "create_document", true},
{"admin", "approve_version", false},
{"data_protection_officer", "approve_version", true},
}
for _, tt := range tests {
t.Run(tt.role+":"+tt.action, func(t *testing.T) {
rolePerms, ok := permissions[tt.role]
if !ok {
t.Fatalf("Unknown role: %s", tt.role)
}
hasPermission := rolePerms[tt.action]
if hasPermission != tt.shouldHave {
t.Errorf("Role %s action %s: expected %v, got %v",
tt.role, tt.action, tt.shouldHave, hasPermission)
}
})
}
}
// TestJSONResponseFormat tests that responses have correct format
func TestJSONResponseFormat(t *testing.T) {
router := setupTestRouter()
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"id": "123",
"name": "Test",
},
})
})
req, _ := http.NewRequest("GET", "/api/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
contentType := w.Header().Get("Content-Type")
if contentType != "application/json; charset=utf-8" {
t.Errorf("Expected Content-Type 'application/json; charset=utf-8', got %s", contentType)
}
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
if err != nil {
t.Fatalf("Response should be valid JSON: %v", err)
}
}
// TestErrorResponseFormat tests error response format
func TestErrorResponseFormat(t *testing.T) {
router := setupTestRouter()
router.GET("/api/error", func(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Bad Request",
"message": "Invalid input",
})
})
req, _ := http.NewRequest("GET", "/api/error", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
var response map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &response)
if response["error"] == nil {
t.Error("Error response should contain 'error' field")
}
}
// TestCookieCategoryValidation tests cookie category validation
func TestCookieCategoryValidation(t *testing.T) {
mandatoryCategories := []string{"necessary"}
optionalCategories := []string{"functional", "analytics", "marketing"}
// Necessary should always be consented
for _, cat := range mandatoryCategories {
t.Run("mandatory_"+cat, func(t *testing.T) {
// Business rule: mandatory categories cannot be declined
isMandatory := true
if !isMandatory {
t.Errorf("Category %s should be mandatory", cat)
}
})
}
// Optional categories can be toggled
for _, cat := range optionalCategories {
t.Run("optional_"+cat, func(t *testing.T) {
isMandatory := false
if isMandatory {
t.Errorf("Category %s should not be mandatory", cat)
}
})
}
}
// TestPaginationParams tests pagination parameter handling
func TestPaginationParams(t *testing.T) {
tests := []struct {
name string
page int
perPage int
expPage int
expLimit int
}{
{"defaults", 0, 0, 1, 50},
{"page 1", 1, 10, 1, 10},
{"page 5", 5, 20, 5, 20},
{"negative page", -1, 10, 1, 10}, // should default
{"too large per_page", 1, 500, 1, 100}, // should cap
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
page := tt.page
perPage := tt.perPage
// Apply defaults and limits
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
if perPage > 100 {
perPage = 100
}
if page != tt.expPage {
t.Errorf("Expected page %d, got %d", tt.expPage, page)
}
if perPage != tt.expLimit {
t.Errorf("Expected perPage %d, got %d", tt.expLimit, perPage)
}
})
}
}
// TestIPAddressExtraction tests IP address extraction from requests
func TestIPAddressExtraction(t *testing.T) {
tests := []struct {
name string
xForwarded string
remoteAddr string
expected string
}{
{"direct connection", "", "192.168.1.1:1234", "192.168.1.1"},
{"behind proxy", "10.0.0.1", "192.168.1.1:1234", "10.0.0.1"},
{"multiple proxies", "10.0.0.1, 10.0.0.2", "192.168.1.1:1234", "10.0.0.1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := setupTestRouter()
var extractedIP string
router.GET("/test", func(c *gin.Context) {
if xf := c.GetHeader("X-Forwarded-For"); xf != "" {
// Take first IP from list
for i, ch := range xf {
if ch == ',' {
extractedIP = xf[:i]
break
}
}
if extractedIP == "" {
extractedIP = xf
}
} else {
// Extract IP from RemoteAddr
addr := c.Request.RemoteAddr
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
extractedIP = addr[:i]
break
}
}
}
c.JSON(http.StatusOK, gin.H{"ip": extractedIP})
})
req, _ := http.NewRequest("GET", "/test", nil)
req.RemoteAddr = tt.remoteAddr
if tt.xForwarded != "" {
req.Header.Set("X-Forwarded-For", tt.xForwarded)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if extractedIP != tt.expected {
t.Errorf("Expected IP %s, got %s", tt.expected, extractedIP)
}
})
}
}
// TestRequestBodySizeLimit tests that large requests are rejected
func TestRequestBodySizeLimit(t *testing.T) {
router := setupTestRouter()
// Simulate a body size limit check
maxBodySize := int64(1024 * 1024) // 1MB
router.POST("/api/upload", func(c *gin.Context) {
if c.Request.ContentLength > maxBodySize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": "Request body too large",
})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
tests := []struct {
name string
contentLength int64
expectedStatus int
}{
{"small body", 1000, http.StatusOK},
{"medium body", 500000, http.StatusOK},
{"exactly at limit", maxBodySize, http.StatusOK},
{"over limit", maxBodySize + 1, http.StatusRequestEntityTooLarge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := bytes.NewReader(make([]byte, 0))
req, _ := http.NewRequest("POST", "/api/upload", body)
req.ContentLength = tt.contentLength
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// ========================================
// EXTENDED HANDLER TESTS
// ========================================
// TestAuthHandlers tests authentication endpoints
func TestAuthHandlers(t *testing.T) {
router := setupTestRouter()
// Register endpoint
router.POST("/api/v1/auth/register", func(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "User registered"})
})
// Login endpoint
router.POST("/api/v1/auth/login", func(c *gin.Context) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
c.JSON(http.StatusOK, gin.H{"access_token": "token123"})
})
tests := []struct {
name string
endpoint string
method string
body interface{}
expectedStatus int
}{
{
name: "register - valid",
endpoint: "/api/v1/auth/register",
method: "POST",
body: map[string]string{"email": "test@example.com", "password": "password123"},
expectedStatus: http.StatusCreated,
},
{
name: "login - valid",
endpoint: "/api/v1/auth/login",
method: "POST",
body: map[string]string{"email": "test@example.com", "password": "password123"},
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
jsonBody, _ := json.Marshal(tt.body)
req, _ := http.NewRequest(tt.method, tt.endpoint, bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// TestDocumentHandlers tests document endpoints
func TestDocumentHandlers(t *testing.T) {
router := setupTestRouter()
// GET documents
router.GET("/api/v1/documents", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}})
})
// GET document by type
router.GET("/api/v1/documents/:type", func(c *gin.Context) {
docType := c.Param("type")
if docType == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid type"})
return
}
c.JSON(http.StatusOK, gin.H{"id": "123", "type": docType})
})
tests := []struct {
name string
endpoint string
expectedStatus int
}{
{"get all documents", "/api/v1/documents", http.StatusOK},
{"get terms", "/api/v1/documents/terms", http.StatusOK},
{"get privacy", "/api/v1/documents/privacy", http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", tt.endpoint, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// TestConsentHandlers tests consent endpoints
func TestConsentHandlers(t *testing.T) {
router := setupTestRouter()
// Create consent
router.POST("/api/v1/consent", func(c *gin.Context) {
var req struct {
VersionID string `json:"version_id"`
Consented bool `json:"consented"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Consent saved"})
})
// Check consent
router.GET("/api/v1/consent/check/:type", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"has_consent": true, "needs_update": false})
})
tests := []struct {
name string
endpoint string
method string
body interface{}
expectedStatus int
}{
{
name: "create consent",
endpoint: "/api/v1/consent",
method: "POST",
body: map[string]interface{}{"version_id": "123", "consented": true},
expectedStatus: http.StatusCreated,
},
{
name: "check consent",
endpoint: "/api/v1/consent/check/terms",
method: "GET",
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var req *http.Request
if tt.body != nil {
jsonBody, _ := json.Marshal(tt.body)
req, _ = http.NewRequest(tt.method, tt.endpoint, bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
} else {
req, _ = http.NewRequest(tt.method, tt.endpoint, nil)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// TestAdminHandlers tests admin endpoints
func TestAdminHandlers(t *testing.T) {
router := setupTestRouter()
// Create document (admin only)
router.POST("/api/v1/admin/documents", func(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth != "Bearer admin-token" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin only"})
return
}
c.JSON(http.StatusCreated, gin.H{"message": "Document created"})
})
tests := []struct {
name string
token string
expectedStatus int
}{
{"admin token", "Bearer admin-token", http.StatusCreated},
{"user token", "Bearer user-token", http.StatusForbidden},
{"no token", "", http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := map[string]string{"type": "terms", "name": "Test"}
jsonBody, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "/api/v1/admin/documents", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
if tt.token != "" {
req.Header.Set("Authorization", tt.token)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
})
}
}
// TestCORSHeaders tests CORS headers
func TestCORSHeaders(t *testing.T) {
router := setupTestRouter()
router.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Next()
})
router.GET("/api/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "test"})
})
req, _ := http.NewRequest("GET", "/api/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("CORS headers not set correctly")
}
}
// TestRateLimiting tests rate limiting logic
func TestRateLimiting(t *testing.T) {
requests := 0
limit := 5
for i := 0; i < 10; i++ {
requests++
if requests > limit {
// Would return 429 Too Many Requests
if requests <= limit {
t.Error("Rate limit not enforced")
}
}
}
}
// TestEmailTemplateHandlers tests email template endpoints
func TestEmailTemplateHandlers(t *testing.T) {
router := setupTestRouter()
router.GET("/api/v1/admin/email-templates", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"templates": []interface{}{}})
})
router.POST("/api/v1/admin/email-templates/test", func(c *gin.Context) {
var req struct {
Recipient string `json:"recipient"`
VersionID string `json:"version_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Test email sent"})
})
req, _ := http.NewRequest("GET", "/api/v1/admin/email-templates", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
}

View File

@@ -0,0 +1,203 @@
package handlers
import (
"net/http"
"strconv"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// NotificationHandler handles notification-related requests
type NotificationHandler struct {
notificationService *services.NotificationService
}
// NewNotificationHandler creates a new notification handler
func NewNotificationHandler(notificationService *services.NotificationService) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
}
}
// GetNotifications returns notifications for the current user
func (h *NotificationHandler) GetNotifications(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
// Parse query parameters
limit := 20
offset := 0
unreadOnly := false
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
if u := c.Query("unread_only"); u == "true" {
unreadOnly = true
}
notifications, total, err := h.notificationService.GetUserNotifications(c.Request.Context(), userID, limit, offset, unreadOnly)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"})
return
}
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"total": total,
"limit": limit,
"offset": offset,
})
}
// GetUnreadCount returns the count of unread notifications
func (h *NotificationHandler) GetUnreadCount(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
count, err := h.notificationService.GetUnreadCount(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get unread count"})
return
}
c.JSON(http.StatusOK, gin.H{"unread_count": count})
}
// MarkAsRead marks a notification as read
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
notificationID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
if err := h.notificationService.MarkAsRead(c.Request.Context(), userID, notificationID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found or already read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
}
// MarkAllAsRead marks all notifications as read
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
if err := h.notificationService.MarkAllAsRead(c.Request.Context(), userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications as read"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
}
// DeleteNotification deletes a notification
func (h *NotificationHandler) DeleteNotification(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
notificationID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
return
}
if err := h.notificationService.DeleteNotification(c.Request.Context(), userID, notificationID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Notification deleted"})
}
// GetPreferences returns notification preferences for the user
func (h *NotificationHandler) GetPreferences(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
prefs, err := h.notificationService.GetPreferences(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get preferences"})
return
}
c.JSON(http.StatusOK, prefs)
}
// UpdatePreferences updates notification preferences for the user
func (h *NotificationHandler) UpdatePreferences(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
return
}
var req struct {
EmailEnabled *bool `json:"email_enabled"`
PushEnabled *bool `json:"push_enabled"`
InAppEnabled *bool `json:"in_app_enabled"`
ReminderFrequency *string `json:"reminder_frequency"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Get current preferences
prefs, _ := h.notificationService.GetPreferences(c.Request.Context(), userID)
// Update only provided fields
if req.EmailEnabled != nil {
prefs.EmailEnabled = *req.EmailEnabled
}
if req.PushEnabled != nil {
prefs.PushEnabled = *req.PushEnabled
}
if req.InAppEnabled != nil {
prefs.InAppEnabled = *req.InAppEnabled
}
if req.ReminderFrequency != nil {
prefs.ReminderFrequency = *req.ReminderFrequency
}
if err := h.notificationService.UpdatePreferences(c.Request.Context(), userID, prefs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Preferences updated", "preferences": prefs})
}

View File

@@ -0,0 +1,743 @@
package handlers
import (
"context"
"net/http"
"strings"
"github.com/breakpilot/consent-service/internal/middleware"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// OAuthHandler handles OAuth 2.0 endpoints
type OAuthHandler struct {
oauthService *services.OAuthService
totpService *services.TOTPService
authService *services.AuthService
}
// NewOAuthHandler creates a new OAuthHandler
func NewOAuthHandler(oauthService *services.OAuthService, totpService *services.TOTPService, authService *services.AuthService) *OAuthHandler {
return &OAuthHandler{
oauthService: oauthService,
totpService: totpService,
authService: authService,
}
}
// ========================================
// OAuth 2.0 Authorization Code Flow
// ========================================
// Authorize handles the OAuth 2.0 authorization request
// GET /oauth/authorize
func (h *OAuthHandler) Authorize(c *gin.Context) {
responseType := c.Query("response_type")
clientID := c.Query("client_id")
redirectURI := c.Query("redirect_uri")
scope := c.Query("scope")
state := c.Query("state")
codeChallenge := c.Query("code_challenge")
codeChallengeMethod := c.Query("code_challenge_method")
// Validate response_type
if responseType != "code" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported_response_type",
"error_description": "Only 'code' response_type is supported",
})
return
}
// Validate client
ctx := context.Background()
client, err := h.oauthService.ValidateClient(ctx, clientID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_client",
"error_description": "Unknown or invalid client_id",
})
return
}
// Validate redirect_uri
if err := h.oauthService.ValidateRedirectURI(client, redirectURI); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Invalid redirect_uri",
})
return
}
// Validate scopes
scopes, err := h.oauthService.ValidateScopes(client, scope)
if err != nil {
redirectWithError(c, redirectURI, "invalid_scope", "One or more requested scopes are invalid", state)
return
}
// For public clients, PKCE is required
if client.IsPublic && codeChallenge == "" {
redirectWithError(c, redirectURI, "invalid_request", "PKCE code_challenge is required for public clients", state)
return
}
// Get authenticated user
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
// User not authenticated - redirect to login
// Store authorization request in session and redirect to login
c.JSON(http.StatusUnauthorized, gin.H{
"error": "login_required",
"error_description": "User must be authenticated to authorize",
"login_url": "/auth/login",
})
return
}
// Generate authorization code
code, err := h.oauthService.GenerateAuthorizationCode(
ctx, client, userID, redirectURI, scopes, codeChallenge, codeChallengeMethod,
)
if err != nil {
redirectWithError(c, redirectURI, "server_error", "Failed to generate authorization code", state)
return
}
// Redirect with code
redirectURL := redirectURI + "?code=" + code
if state != "" {
redirectURL += "&state=" + state
}
c.Redirect(http.StatusFound, redirectURL)
}
// Token handles the OAuth 2.0 token request
// POST /oauth/token
func (h *OAuthHandler) Token(c *gin.Context) {
grantType := c.PostForm("grant_type")
switch grantType {
case "authorization_code":
h.tokenAuthorizationCode(c)
case "refresh_token":
h.tokenRefreshToken(c)
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported_grant_type",
"error_description": "Only 'authorization_code' and 'refresh_token' grant types are supported",
})
}
}
// tokenAuthorizationCode handles the authorization_code grant
func (h *OAuthHandler) tokenAuthorizationCode(c *gin.Context) {
code := c.PostForm("code")
clientID := c.PostForm("client_id")
redirectURI := c.PostForm("redirect_uri")
codeVerifier := c.PostForm("code_verifier")
if code == "" || clientID == "" || redirectURI == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing required parameters: code, client_id, redirect_uri",
})
return
}
// Validate client
ctx := context.Background()
client, err := h.oauthService.ValidateClient(ctx, clientID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_client",
"error_description": "Unknown or invalid client_id",
})
return
}
// For confidential clients, validate client_secret
if !client.IsPublic {
clientSecret := c.PostForm("client_secret")
if err := h.oauthService.ValidateClientSecret(client, clientSecret); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "invalid_client",
"error_description": "Invalid client credentials",
})
return
}
}
// Exchange authorization code for tokens
tokenResponse, err := h.oauthService.ExchangeAuthorizationCode(ctx, code, clientID, redirectURI, codeVerifier)
if err != nil {
switch err {
case services.ErrCodeExpired:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": "Authorization code has expired",
})
case services.ErrCodeUsed:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": "Authorization code has already been used",
})
case services.ErrPKCEVerifyFailed:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": "PKCE verification failed",
})
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": "Invalid authorization code",
})
}
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// tokenRefreshToken handles the refresh_token grant
func (h *OAuthHandler) tokenRefreshToken(c *gin.Context) {
refreshToken := c.PostForm("refresh_token")
clientID := c.PostForm("client_id")
scope := c.PostForm("scope")
if refreshToken == "" || clientID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing required parameters: refresh_token, client_id",
})
return
}
ctx := context.Background()
// Refresh access token
tokenResponse, err := h.oauthService.RefreshAccessToken(ctx, refreshToken, clientID, scope)
if err != nil {
switch err {
case services.ErrInvalidScope:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_scope",
"error_description": "Requested scope exceeds original grant",
})
default:
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_grant",
"error_description": "Invalid or expired refresh token",
})
}
return
}
c.JSON(http.StatusOK, tokenResponse)
}
// Revoke handles token revocation
// POST /oauth/revoke
func (h *OAuthHandler) Revoke(c *gin.Context) {
token := c.PostForm("token")
tokenTypeHint := c.PostForm("token_type_hint")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
ctx := context.Background()
_ = h.oauthService.RevokeToken(ctx, token, tokenTypeHint)
// RFC 7009: Always return 200 OK
c.Status(http.StatusOK)
}
// Introspect handles token introspection (for resource servers)
// POST /oauth/introspect
func (h *OAuthHandler) Introspect(c *gin.Context) {
token := c.PostForm("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing token parameter",
})
return
}
ctx := context.Background()
claims, err := h.oauthService.ValidateAccessToken(ctx, token)
if err != nil {
c.JSON(http.StatusOK, gin.H{"active": false})
return
}
c.JSON(http.StatusOK, gin.H{
"active": true,
"sub": (*claims)["sub"],
"client_id": (*claims)["client_id"],
"scope": (*claims)["scope"],
"exp": (*claims)["exp"],
"iat": (*claims)["iat"],
"iss": (*claims)["iss"],
})
}
// ========================================
// 2FA (TOTP) Endpoints
// ========================================
// Setup2FA initiates 2FA setup
// POST /auth/2fa/setup
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
// Get user email
ctx := context.Background()
user, err := h.authService.GetUserByID(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Setup 2FA
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
}
return
}
c.JSON(http.StatusOK, response)
}
// Verify2FASetup verifies the 2FA setup with a code
// POST /auth/2fa/verify-setup
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPAlreadyEnabled:
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
}
// Verify2FAChallenge verifies a 2FA challenge during login
// POST /auth/2fa/verify
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
var req models.Verify2FAChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
var userID *uuid.UUID
var err error
if req.RecoveryCode != "" {
// Verify with recovery code
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
} else {
// Verify with TOTP code
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
}
if err != nil {
switch err {
case services.ErrTOTPChallengeExpired:
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
case services.ErrRecoveryCodeInvalid:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
}
return
}
// Get user and generate tokens
user, err := h.authService.GetUserByID(ctx, *userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// Generate access token
accessToken, err := h.authService.GenerateAccessToken(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
// Generate refresh token
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
return
}
// Store session
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// We need direct DB access for this, or we need to add a method to AuthService
// For now, we'll return the tokens and let the caller handle session storage
c.JSON(http.StatusOK, gin.H{
"access_token": accessToken,
"refresh_token": refreshToken,
"token_type": "Bearer",
"expires_in": 3600,
"user": map[string]interface{}{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
},
"_session_hash": refreshTokenHash,
"_ip": ipAddress,
"_user_agent": userAgent,
})
}
// Disable2FA disables 2FA for the current user
// POST /auth/2fa/disable
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
err = h.totpService.Disable2FA(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
}
// Get2FAStatus returns the 2FA status for the current user
// GET /auth/2fa/status
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
ctx := context.Background()
status, err := h.totpService.GetStatus(ctx, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
return
}
c.JSON(http.StatusOK, status)
}
// RegenerateRecoveryCodes generates new recovery codes
// POST /auth/2fa/recovery-codes
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
userID, err := middleware.GetUserID(c)
if err != nil || userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
return
}
var req models.Verify2FARequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
if err != nil {
switch err {
case services.ErrTOTPNotEnabled:
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
case services.ErrTOTPInvalidCode:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
}
return
}
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
}
// ========================================
// Enhanced Login with 2FA
// ========================================
// LoginWith2FA handles login with optional 2FA
// POST /auth/login
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
ipAddress := middleware.GetClientIP(c)
userAgent := middleware.GetUserAgent(c)
// Attempt login
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
if err != nil {
switch err {
case services.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
case services.ErrAccountLocked:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
case services.ErrAccountSuspended:
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
}
return
}
// Check if 2FA is enabled
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
if twoFactorEnabled {
// Create 2FA challenge
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
return
}
// Return 2FA required response
c.JSON(http.StatusOK, gin.H{
"requires_2fa": true,
"challenge_id": challengeID,
"message": "2FA verification required",
})
return
}
// No 2FA required, return tokens
c.JSON(http.StatusOK, gin.H{
"requires_2fa": false,
"access_token": response.AccessToken,
"refresh_token": response.RefreshToken,
"token_type": "Bearer",
"expires_in": response.ExpiresIn,
"user": map[string]interface{}{
"id": response.User.ID,
"email": response.User.Email,
"name": response.User.Name,
"role": response.User.Role,
},
})
}
// ========================================
// Registration with mandatory 2FA setup
// ========================================
// RegisterWith2FA handles registration with mandatory 2FA setup
// POST /auth/register
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
ctx := context.Background()
// Validate password strength
if len(req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
return
}
// Register user
user, verificationToken, err := h.authService.Register(ctx, &req)
if err != nil {
switch err {
case services.ErrUserExists:
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
}
return
}
// Setup 2FA immediately
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
if err != nil {
// Non-fatal - user can set up 2FA later, but log it
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": nil,
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
})
return
}
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successful. Please verify your email and complete 2FA setup.",
"user_id": user.ID,
"verification_token": verificationToken, // In production, this would be sent via email
"two_factor_setup": map[string]interface{}{
"secret": twoFAResponse.Secret,
"qr_code": twoFAResponse.QRCodeDataURL,
"recovery_codes": twoFAResponse.RecoveryCodes,
"setup_required": true,
"setup_endpoint": "/auth/2fa/verify-setup",
},
})
}
// ========================================
// OAuth Client Management (Admin)
// ========================================
// AdminCreateClient creates a new OAuth client
// POST /admin/oauth/clients
func (h *OAuthHandler) AdminCreateClient(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
RedirectURIs []string `json:"redirect_uris" binding:"required"`
Scopes []string `json:"scopes"`
GrantTypes []string `json:"grant_types"`
IsPublic bool `json:"is_public"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
userID, _ := middleware.GetUserID(c)
// Default scopes
if len(req.Scopes) == 0 {
req.Scopes = []string{"openid", "profile", "email"}
}
// Default grant types
if len(req.GrantTypes) == 0 {
req.GrantTypes = []string{"authorization_code", "refresh_token"}
}
ctx := context.Background()
client, clientSecret, err := h.oauthService.CreateClient(
ctx, req.Name, req.Description, req.RedirectURIs, req.Scopes, req.GrantTypes, req.IsPublic, &userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create client"})
return
}
response := gin.H{
"client_id": client.ClientID,
"name": client.Name,
"redirect_uris": client.RedirectURIs,
"scopes": client.Scopes,
"grant_types": client.GrantTypes,
"is_public": client.IsPublic,
}
// Only show client_secret once for confidential clients
if !client.IsPublic && clientSecret != "" {
response["client_secret"] = clientSecret
response["client_secret_warning"] = "Store this secret securely. It will not be shown again."
}
c.JSON(http.StatusCreated, response)
}
// AdminGetClients lists all OAuth clients
// GET /admin/oauth/clients
func (h *OAuthHandler) AdminGetClients(c *gin.Context) {
// This would need a new method in OAuthService
// For now, return a placeholder
c.JSON(http.StatusOK, gin.H{
"clients": []interface{}{},
"message": "Client listing not yet implemented",
})
}
// ========================================
// Helper Functions
// ========================================
func redirectWithError(c *gin.Context, redirectURI, errorCode, errorDescription, state string) {
separator := "?"
if strings.Contains(redirectURI, "?") {
separator = "&"
}
redirectURL := redirectURI + separator + "error=" + errorCode + "&error_description=" + errorDescription
if state != "" {
redirectURL += "&state=" + state
}
c.Redirect(http.StatusFound, redirectURL)
}

View File

@@ -0,0 +1,933 @@
package handlers
import (
"net/http"
"time"
"github.com/breakpilot/consent-service/internal/models"
"github.com/breakpilot/consent-service/internal/services"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// SchoolHandlers contains all school-related HTTP handlers
type SchoolHandlers struct {
schoolService *services.SchoolService
attendanceService *services.AttendanceService
gradeService *services.GradeService
}
// NewSchoolHandlers creates new school handlers
func NewSchoolHandlers(schoolService *services.SchoolService, attendanceService *services.AttendanceService, gradeService *services.GradeService) *SchoolHandlers {
return &SchoolHandlers{
schoolService: schoolService,
attendanceService: attendanceService,
gradeService: gradeService,
}
}
// ========================================
// School Handlers
// ========================================
// CreateSchool creates a new school
// POST /api/v1/schools
func (h *SchoolHandlers) CreateSchool(c *gin.Context) {
var req models.CreateSchoolRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
school, err := h.schoolService.CreateSchool(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, school)
}
// GetSchool retrieves a school by ID
// GET /api/v1/schools/:id
func (h *SchoolHandlers) GetSchool(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
school, err := h.schoolService.GetSchool(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, school)
}
// ListSchools lists all schools
// GET /api/v1/schools
func (h *SchoolHandlers) ListSchools(c *gin.Context) {
schools, err := h.schoolService.ListSchools(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, schools)
}
// ========================================
// School Year Handlers
// ========================================
// CreateSchoolYear creates a new school year
// POST /api/v1/schools/:id/years
func (h *SchoolHandlers) CreateSchoolYear(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
StartDate string `json:"start_date" binding:"required"`
EndDate string `json:"end_date" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
startDate, err := time.Parse("2006-01-02", req.StartDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid start date format"})
return
}
endDate, err := time.Parse("2006-01-02", req.EndDate)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid end date format"})
return
}
schoolYear, err := h.schoolService.CreateSchoolYear(c.Request.Context(), schoolID, req.Name, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, schoolYear)
}
// SetCurrentSchoolYear sets a school year as current
// PUT /api/v1/schools/:id/years/:yearId/current
func (h *SchoolHandlers) SetCurrentSchoolYear(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
yearIDStr := c.Param("yearId")
yearID, err := uuid.Parse(yearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
if err := h.schoolService.SetCurrentSchoolYear(c.Request.Context(), schoolID, yearID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "school year set as current"})
}
// ========================================
// Class Handlers
// ========================================
// CreateClass creates a new class
// POST /api/v1/schools/:id/classes
func (h *SchoolHandlers) CreateClass(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
var req models.CreateClassRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
class, err := h.schoolService.CreateClass(c.Request.Context(), schoolID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, class)
}
// GetClass retrieves a class by ID
// GET /api/v1/classes/:id
func (h *SchoolHandlers) GetClass(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
class, err := h.schoolService.GetClass(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, class)
}
// ListClasses lists all classes for a school in a school year
// GET /api/v1/schools/:id/classes?school_year_id=...
func (h *SchoolHandlers) ListClasses(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
// Get current school year
schoolYear, err := h.schoolService.GetCurrentSchoolYear(c.Request.Context(), schoolID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no current school year set"})
return
}
schoolYearIDStr = schoolYear.ID.String()
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
classes, err := h.schoolService.ListClasses(c.Request.Context(), schoolID, schoolYearID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, classes)
}
// ========================================
// Student Handlers
// ========================================
// CreateStudent creates a new student
// POST /api/v1/schools/:id/students
func (h *SchoolHandlers) CreateStudent(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
var req models.CreateStudentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
student, err := h.schoolService.CreateStudent(c.Request.Context(), schoolID, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, student)
}
// GetStudent retrieves a student by ID
// GET /api/v1/students/:id
func (h *SchoolHandlers) GetStudent(c *gin.Context) {
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
student, err := h.schoolService.GetStudent(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, student)
}
// ListStudentsByClass lists all students in a class
// GET /api/v1/classes/:id/students
func (h *SchoolHandlers) ListStudentsByClass(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
students, err := h.schoolService.ListStudentsByClass(c.Request.Context(), classID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, students)
}
// ========================================
// Subject Handlers
// ========================================
// CreateSubject creates a new subject
// POST /api/v1/schools/:id/subjects
func (h *SchoolHandlers) CreateSubject(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
ShortName string `json:"short_name" binding:"required"`
Color *string `json:"color"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
subject, err := h.schoolService.CreateSubject(c.Request.Context(), schoolID, req.Name, req.ShortName, req.Color)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, subject)
}
// ListSubjects lists all subjects for a school
// GET /api/v1/schools/:id/subjects
func (h *SchoolHandlers) ListSubjects(c *gin.Context) {
schoolIDStr := c.Param("id")
schoolID, err := uuid.Parse(schoolIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
subjects, err := h.schoolService.ListSubjects(c.Request.Context(), schoolID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, subjects)
}
// ========================================
// Attendance Handlers
// ========================================
// RecordAttendance records attendance for a student
// POST /api/v1/attendance
func (h *SchoolHandlers) RecordAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.RecordAttendanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, record)
}
// RecordBulkAttendance records attendance for multiple students
// POST /api/v1/classes/:id/attendance
func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
var req struct {
Date string `json:"date" binding:"required"`
SlotID string `json:"slot_id" binding:"required"`
Records []struct {
StudentID string `json:"student_id"`
Status string `json:"status"`
Note *string `json:"note"`
} `json:"records" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
slotID, err := uuid.Parse(req.SlotID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"})
return
}
// Convert to the expected type (without JSON tags)
records := make([]struct {
StudentID string
Status string
Note *string
}, len(req.Records))
for i, r := range req.Records {
records[i] = struct {
StudentID string
Status string
Note *string
}{
StudentID: r.StudentID,
Status: r.Status,
Note: r.Note,
}
}
err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"})
}
// GetClassAttendance gets attendance for a class on a specific date
// GET /api/v1/classes/:id/attendance?date=...
func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
date := c.Query("date")
if date == "" {
date = time.Now().Format("2006-01-02")
}
overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overview)
}
// GetStudentAttendance gets attendance history for a student
// GET /api/v1/students/:id/attendance?start_date=...&end_date=...
func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
var startDate, endDate time.Time
if startDateStr == "" {
startDate = time.Now().AddDate(0, -1, 0) // Last month
} else {
startDate, _ = time.Parse("2006-01-02", startDateStr)
}
if endDateStr == "" {
endDate = time.Now()
} else {
endDate, _ = time.Parse("2006-01-02", endDateStr)
}
records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, records)
}
// ========================================
// Absence Report Handlers
// ========================================
// ReportAbsence allows parents to report absence
// POST /api/v1/absence/report
func (h *SchoolHandlers) ReportAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.ReportAbsenceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, report)
}
// ConfirmAbsence allows teachers to confirm absence
// PUT /api/v1/absence/:id/confirm
func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
reportIDStr := c.Param("id")
reportID, err := uuid.Parse(reportIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"})
return
}
var req struct {
Status string `json:"status" binding:"required"` // "excused" or "unexcused"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"})
}
// GetPendingAbsenceReports gets pending absence reports for a class
// GET /api/v1/classes/:id/absence/pending
func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, reports)
}
// ========================================
// Grade Handlers
// ========================================
// CreateGrade creates a new grade
// POST /api/v1/grades
func (h *SchoolHandlers) CreateGrade(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req models.CreateGradeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Get teacher ID from user ID
teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"})
return
}
grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, grade)
}
// GetStudentGrades gets all grades for a student
// GET /api/v1/students/:id/grades?school_year_id=...
func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) {
studentIDStr := c.Param("id")
studentID, err := uuid.Parse(studentIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, grades)
}
// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel)
// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=...
func (h *SchoolHandlers) GetClassGrades(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, overviews)
}
// GetGradeStatistics gets grade statistics for a class/subject
// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=...
func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) {
classIDStr := c.Param("id")
classID, err := uuid.Parse(classIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
subjectIDStr := c.Param("subjectId")
subjectID, err := uuid.Parse(subjectIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"})
return
}
schoolYearIDStr := c.Query("school_year_id")
if schoolYearIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"})
return
}
schoolYearID, err := uuid.Parse(schoolYearIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"})
return
}
semesterStr := c.DefaultQuery("semester", "1")
var semester int
if semesterStr == "1" {
semester = 1
} else {
semester = 2
}
stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
// ========================================
// Parent Onboarding Handlers
// ========================================
// GenerateOnboardingToken generates a QR code token for parent onboarding
// POST /api/v1/onboarding/tokens
func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
SchoolID string `json:"school_id" binding:"required"`
ClassID string `json:"class_id" binding:"required"`
StudentID string `json:"student_id" binding:"required"`
Role string `json:"role"` // "parent" or "parent_representative"
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
schoolID, err := uuid.Parse(req.SchoolID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"})
return
}
classID, err := uuid.Parse(req.ClassID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"})
return
}
studentID, err := uuid.Parse(req.StudentID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"})
return
}
role := req.Role
if role == "" {
role = "parent"
}
token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Generate QR code URL
qrURL := "/onboard-parent?token=" + token.Token
c.JSON(http.StatusCreated, gin.H{
"token": token.Token,
"qr_url": qrURL,
"expires_at": token.ExpiresAt,
})
}
// ValidateOnboardingToken validates an onboarding token
// GET /api/v1/onboarding/validate?token=...
func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) {
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
return
}
onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"})
return
}
// Get student and school info
student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"valid": true,
"role": onboardingToken.Role,
"student_name": student.FirstName + " " + student.LastName,
"class_name": class.Name,
"school_name": school.Name,
"expires_at": onboardingToken.ExpiresAt,
})
}
// RedeemOnboardingToken redeems a token and creates parent account
// POST /api/v1/onboarding/redeem
func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
return
}
var req struct {
Token string `json:"token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"})
}
// ========================================
// Register Routes
// ========================================
// RegisterRoutes registers all school-related routes
func (h *SchoolHandlers) RegisterRoutes(r *gin.RouterGroup, authMiddleware gin.HandlerFunc) {
// Public routes (for onboarding)
r.GET("/onboarding/validate", h.ValidateOnboardingToken)
// Protected routes
protected := r.Group("")
protected.Use(authMiddleware)
// Schools
protected.POST("/schools", h.CreateSchool)
protected.GET("/schools", h.ListSchools)
protected.GET("/schools/:id", h.GetSchool)
protected.POST("/schools/:id/years", h.CreateSchoolYear)
protected.PUT("/schools/:id/years/:yearId/current", h.SetCurrentSchoolYear)
protected.POST("/schools/:id/classes", h.CreateClass)
protected.GET("/schools/:id/classes", h.ListClasses)
protected.POST("/schools/:id/students", h.CreateStudent)
protected.POST("/schools/:id/subjects", h.CreateSubject)
protected.GET("/schools/:id/subjects", h.ListSubjects)
// Classes
protected.GET("/classes/:id", h.GetClass)
protected.GET("/classes/:id/students", h.ListStudentsByClass)
protected.GET("/classes/:id/attendance", h.GetClassAttendance)
protected.POST("/classes/:id/attendance", h.RecordBulkAttendance)
protected.GET("/classes/:id/absence/pending", h.GetPendingAbsenceReports)
protected.GET("/classes/:id/grades/:subjectId", h.GetClassGrades)
protected.GET("/classes/:id/grades/:subjectId/stats", h.GetGradeStatistics)
// Students
protected.GET("/students/:id", h.GetStudent)
protected.GET("/students/:id/attendance", h.GetStudentAttendance)
protected.GET("/students/:id/grades", h.GetStudentGrades)
// Attendance & Absence
protected.POST("/attendance", h.RecordAttendance)
protected.POST("/absence/report", h.ReportAbsence)
protected.PUT("/absence/:id/confirm", h.ConfirmAbsence)
// Grades
protected.POST("/grades", h.CreateGrade)
// Onboarding
protected.POST("/onboarding/tokens", h.GenerateOnboardingToken)
protected.POST("/onboarding/redeem", h.RedeemOnboardingToken)
}