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:
442
consent-service/internal/handlers/auth_handlers.go
Normal file
442
consent-service/internal/handlers/auth_handlers.go
Normal 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"})
|
||||
}
|
||||
561
consent-service/internal/handlers/banner_handlers.go
Normal file
561
consent-service/internal/handlers/banner_handlers.go
Normal 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)
|
||||
}
|
||||
511
consent-service/internal/handlers/communication_handlers.go
Normal file
511
consent-service/internal/handlers/communication_handlers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
407
consent-service/internal/handlers/communication_handlers_test.go
Normal file
407
consent-service/internal/handlers/communication_handlers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
92
consent-service/internal/handlers/deadline_handlers.go
Normal file
92
consent-service/internal/handlers/deadline_handlers.go
Normal 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"})
|
||||
}
|
||||
948
consent-service/internal/handlers/dsr_handlers.go
Normal file
948
consent-service/internal/handlers/dsr_handlers.go
Normal 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"})
|
||||
}
|
||||
448
consent-service/internal/handlers/dsr_handlers_test.go
Normal file
448
consent-service/internal/handlers/dsr_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
528
consent-service/internal/handlers/email_template_handlers.go
Normal file
528
consent-service/internal/handlers/email_template_handlers.go
Normal 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"})
|
||||
}
|
||||
1783
consent-service/internal/handlers/handlers.go
Normal file
1783
consent-service/internal/handlers/handlers.go
Normal file
File diff suppressed because it is too large
Load Diff
805
consent-service/internal/handlers/handlers_test.go
Normal file
805
consent-service/internal/handlers/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
203
consent-service/internal/handlers/notification_handlers.go
Normal file
203
consent-service/internal/handlers/notification_handlers.go
Normal 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})
|
||||
}
|
||||
743
consent-service/internal/handlers/oauth_handlers.go
Normal file
743
consent-service/internal/handlers/oauth_handlers.go
Normal 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)
|
||||
}
|
||||
933
consent-service/internal/handlers/school_handlers.go
Normal file
933
consent-service/internal/handlers/school_handlers.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user