[split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
455
consent-service/internal/handlers/admin_approval.go
Normal file
455
consent-service/internal/handlers/admin_approval.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS - Version Approval Workflow (DSB)
|
||||
// ========================================
|
||||
|
||||
// AdminSubmitForReview submits a version for DSB review
|
||||
func (h *Handler) AdminSubmitForReview(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Check current status
|
||||
var status string
|
||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if status != "draft" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status to review
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'review', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log approval action
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||
VALUES ($1, $2, 'submitted', 'Submitted for DSB review')
|
||||
`, versionID, userID)
|
||||
|
||||
h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"})
|
||||
}
|
||||
|
||||
// AdminApproveVersion approves a version with scheduled publish date (DSB only)
|
||||
func (h *Handler) AdminApproveVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is DSB or Admin (for dev purposes)
|
||||
if !middleware.IsDSB(c) && !middleware.IsAdmin(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Comment string `json:"comment"`
|
||||
ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z"
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
// Validate scheduled publish date
|
||||
var scheduledAt *time.Time
|
||||
if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"})
|
||||
return
|
||||
}
|
||||
if parsed.Before(time.Now()) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"})
|
||||
return
|
||||
}
|
||||
scheduledAt = &parsed
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Check current status
|
||||
var status string
|
||||
var createdBy *uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if status != "review" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"})
|
||||
return
|
||||
}
|
||||
|
||||
// Four-eyes principle: DSB cannot approve their own version
|
||||
// Exception: Admins can approve their own versions for development/testing purposes
|
||||
role, _ := c.Get("role")
|
||||
roleStr, _ := role.(string)
|
||||
if createdBy != nil && *createdBy == userID && roleStr != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"})
|
||||
return
|
||||
}
|
||||
|
||||
// Determine new status: 'scheduled' if date set, otherwise 'approved'
|
||||
newStatus := "approved"
|
||||
if scheduledAt != nil {
|
||||
newStatus = "scheduled"
|
||||
}
|
||||
|
||||
// Update status to approved/scheduled
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID, newStatus, userID, scheduledAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log approval action
|
||||
comment := req.Comment
|
||||
if comment == "" {
|
||||
if scheduledAt != nil {
|
||||
comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04")
|
||||
} else {
|
||||
comment = "Approved by DSB"
|
||||
}
|
||||
}
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||
VALUES ($1, $2, 'approved', $3)
|
||||
`, versionID, userID, comment)
|
||||
|
||||
h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent)
|
||||
|
||||
response := gin.H{"message": "Version approved", "status": newStatus}
|
||||
if scheduledAt != nil {
|
||||
response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339)
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// AdminRejectVersion rejects a version (DSB only)
|
||||
func (h *Handler) AdminRejectVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is DSB
|
||||
if !middleware.IsDSB(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"})
|
||||
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 when rejecting"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Check current status
|
||||
var status string
|
||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if status != "review" && status != "approved" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update status back to draft
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'draft', approved_by = NULL, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log rejection
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO version_approvals (version_id, approver_id, action, comment)
|
||||
VALUES ($1, $2, 'rejected', $3)
|
||||
`, versionID, userID, req.Comment)
|
||||
|
||||
h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"})
|
||||
}
|
||||
|
||||
// AdminCompareVersions returns two versions for side-by-side comparison
|
||||
func (h *Handler) AdminCompareVersions(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get the current version and its document
|
||||
var currentVersion models.DocumentVersion
|
||||
var documentID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at
|
||||
FROM document_versions
|
||||
WHERE id = $1
|
||||
`, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language,
|
||||
¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status,
|
||||
¤tVersion.CreatedAt, ¤tVersion.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get the currently published version (if any)
|
||||
var publishedVersion *models.DocumentVersion
|
||||
var pv models.DocumentVersion
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at
|
||||
FROM document_versions
|
||||
WHERE document_id = $1 AND language = $2 AND status = 'published'
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1
|
||||
`, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language,
|
||||
&pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt)
|
||||
|
||||
if err == nil && pv.ID != currentVersion.ID {
|
||||
publishedVersion = &pv
|
||||
}
|
||||
|
||||
// Get approval history
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT va.action, va.comment, va.created_at, u.email
|
||||
FROM version_approvals va
|
||||
LEFT JOIN users u ON va.approver_id = u.id
|
||||
WHERE va.version_id = $1
|
||||
ORDER BY va.created_at DESC
|
||||
`, versionID)
|
||||
|
||||
var approvalHistory []map[string]interface{}
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var action, email string
|
||||
var comment *string
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil {
|
||||
approvalHistory = append(approvalHistory, map[string]interface{}{
|
||||
"action": action,
|
||||
"comment": comment,
|
||||
"created_at": createdAt,
|
||||
"approver": email,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current_version": currentVersion,
|
||||
"published_version": publishedVersion,
|
||||
"approval_history": approvalHistory,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminGetApprovalHistory returns the approval history for a version
|
||||
func (h *Handler) AdminGetApprovalHistory(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name
|
||||
FROM version_approvals va
|
||||
LEFT JOIN users u ON va.approver_id = u.id
|
||||
WHERE va.version_id = $1
|
||||
ORDER BY va.created_at DESC
|
||||
`, versionID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var action string
|
||||
var comment *string
|
||||
var createdAt time.Time
|
||||
var email, name *string
|
||||
|
||||
if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
history = append(history, map[string]interface{}{
|
||||
"id": id,
|
||||
"action": action,
|
||||
"comment": comment,
|
||||
"created_at": createdAt,
|
||||
"approver": email,
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"approval_history": history})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// SCHEDULED PUBLISHING
|
||||
// ========================================
|
||||
|
||||
// ProcessScheduledPublishing publishes all versions that are due
|
||||
// This should be called by a cron job or scheduler
|
||||
func (h *Handler) ProcessScheduledPublishing(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Find all scheduled versions that are due
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, document_id, version
|
||||
FROM document_versions
|
||||
WHERE status = 'scheduled'
|
||||
AND scheduled_publish_at IS NOT NULL
|
||||
AND scheduled_publish_at <= NOW()
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var published []string
|
||||
for rows.Next() {
|
||||
var versionID, docID uuid.UUID
|
||||
var version string
|
||||
if err := rows.Scan(&versionID, &docID, &version); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Publish this version
|
||||
_, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'published', published_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID)
|
||||
|
||||
if err == nil {
|
||||
// Archive previous published versions for this document
|
||||
h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'archived', updated_at = NOW()
|
||||
WHERE document_id = $1 AND id != $2 AND status = 'published'
|
||||
`, docID, versionID)
|
||||
|
||||
// Log the publishing
|
||||
details := fmt.Sprintf("Version %s automatically published by scheduler", version)
|
||||
h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler")
|
||||
|
||||
published = append(published, version)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Scheduled publishing processed",
|
||||
"published_count": len(published),
|
||||
"published_versions": published,
|
||||
})
|
||||
}
|
||||
|
||||
// GetScheduledVersions returns all versions scheduled for publishing
|
||||
func (h *Handler) GetScheduledVersions(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name
|
||||
FROM document_versions dv
|
||||
JOIN legal_documents ld ON ld.id = dv.document_id
|
||||
WHERE dv.status = 'scheduled'
|
||||
AND dv.scheduled_publish_at IS NOT NULL
|
||||
ORDER BY dv.scheduled_publish_at ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type ScheduledVersion struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DocumentID uuid.UUID `json:"document_id"`
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title"`
|
||||
ScheduledPublishAt *time.Time `json:"scheduled_publish_at"`
|
||||
DocumentName string `json:"document_name"`
|
||||
}
|
||||
|
||||
var versions []ScheduledVersion
|
||||
for rows.Next() {
|
||||
var v ScheduledVersion
|
||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions})
|
||||
}
|
||||
391
consent-service/internal/handlers/admin_documents.go
Normal file
391
consent-service/internal/handlers/admin_documents.go
Normal file
@@ -0,0 +1,391 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS - Document Management
|
||||
// ========================================
|
||||
|
||||
// AdminGetDocuments returns all documents (including inactive) for admin
|
||||
func (h *Handler) AdminGetDocuments(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||
FROM legal_documents
|
||||
ORDER BY sort_order ASC, created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var documents []models.LegalDocument
|
||||
for rows.Next() {
|
||||
var doc models.LegalDocument
|
||||
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
documents = append(documents, doc)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
||||
}
|
||||
|
||||
// AdminCreateDocument creates a new legal document
|
||||
func (h *Handler) AdminCreateDocument(c *gin.Context) {
|
||||
var req models.CreateDocumentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var docID uuid.UUID
|
||||
err := h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO legal_documents (type, name, description, is_mandatory)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id
|
||||
`, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Document created successfully",
|
||||
"id": docID,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateDocument updates a legal document
|
||||
func (h *Handler) AdminUpdateDocument(c *gin.Context) {
|
||||
docID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
IsMandatory *bool `json:"is_mandatory"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE legal_documents
|
||||
SET name = COALESCE($2, name),
|
||||
description = COALESCE($3, description),
|
||||
is_mandatory = COALESCE($4, is_mandatory),
|
||||
is_active = COALESCE($5, is_active),
|
||||
sort_order = COALESCE($6, sort_order),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"})
|
||||
}
|
||||
|
||||
// AdminDeleteDocument soft-deletes a document (sets is_active to false)
|
||||
func (h *Handler) AdminDeleteDocument(c *gin.Context) {
|
||||
docID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE legal_documents
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, docID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS - Version Management
|
||||
// ========================================
|
||||
|
||||
// AdminGetVersions returns all versions for a document
|
||||
func (h *Handler) AdminGetVersions(c *gin.Context) {
|
||||
docID, err := uuid.Parse(c.Param("docId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, document_id, version, language, title, content, summary, status,
|
||||
published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at
|
||||
FROM document_versions
|
||||
WHERE document_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, docID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var versions []models.DocumentVersion
|
||||
for rows.Next() {
|
||||
var v models.DocumentVersion
|
||||
if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content,
|
||||
&v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"versions": versions})
|
||||
}
|
||||
|
||||
// AdminCreateVersion creates a new document version
|
||||
func (h *Handler) AdminCreateVersion(c *gin.Context) {
|
||||
var req models.CreateVersionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
docID, err := uuid.Parse(req.DocumentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
ctx := context.Background()
|
||||
|
||||
var versionID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7)
|
||||
RETURNING id
|
||||
`, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID)
|
||||
|
||||
if err != nil {
|
||||
// Check for unique constraint violation
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"})
|
||||
return
|
||||
}
|
||||
// Log the actual error for debugging
|
||||
fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Version created successfully",
|
||||
"id": versionID,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateVersion updates a document version
|
||||
func (h *Handler) AdminUpdateVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateVersionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Check if version is in draft or review status (only these can be edited)
|
||||
var status string
|
||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if status != "draft" && status != "review" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET title = COALESCE($2, title),
|
||||
content = COALESCE($3, content),
|
||||
summary = COALESCE($4, summary),
|
||||
status = COALESCE($5, status),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID, req.Title, req.Content, req.Summary, req.Status)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"})
|
||||
}
|
||||
|
||||
// AdminPublishVersion publishes a document version
|
||||
func (h *Handler) AdminPublishVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
ctx := context.Background()
|
||||
|
||||
// Check current status
|
||||
var status string
|
||||
err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if status != "approved" && status != "review" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'published',
|
||||
published_at = NOW(),
|
||||
approved_by = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID, userID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"})
|
||||
}
|
||||
|
||||
// AdminArchiveVersion archives a document version
|
||||
func (h *Handler) AdminArchiveVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE document_versions
|
||||
SET status = 'archived', updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, versionID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"})
|
||||
}
|
||||
|
||||
// AdminDeleteVersion permanently deletes a draft/rejected version
|
||||
// Only draft and rejected versions can be deleted. Published versions must be archived.
|
||||
func (h *Handler) AdminDeleteVersion(c *gin.Context) {
|
||||
versionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First check the version status - only draft/rejected can be deleted
|
||||
var status string
|
||||
var version string
|
||||
var docID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT status, version, document_id FROM document_versions WHERE id = $1
|
||||
`, versionID).Scan(&status, &version, &docID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Only allow deletion of draft and rejected versions
|
||||
if status != "draft" && status != "rejected" {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": "Cannot delete version",
|
||||
"message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.",
|
||||
"status": status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the version
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
DELETE FROM document_versions WHERE id = $1
|
||||
`, versionID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log the deletion
|
||||
userID, _ := c.Get("user_id")
|
||||
h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent)
|
||||
VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5)
|
||||
`, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent())
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Version deleted successfully",
|
||||
"deleted_version": version,
|
||||
"version_id": versionID,
|
||||
})
|
||||
}
|
||||
319
consent-service/internal/handlers/admin_operations.go
Normal file
319
consent-service/internal/handlers/admin_operations.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS - Cookie Categories
|
||||
// ========================================
|
||||
|
||||
// AdminGetCookieCategories returns all cookie categories
|
||||
func (h *Handler) AdminGetCookieCategories(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
||||
is_mandatory, sort_order, is_active, created_at, updated_at
|
||||
FROM cookie_categories
|
||||
ORDER BY sort_order ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []models.CookieCategory
|
||||
for rows.Next() {
|
||||
var cat models.CookieCategory
|
||||
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
||||
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder,
|
||||
&cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
categories = append(categories, cat)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||
}
|
||||
|
||||
// AdminCreateCookieCategory creates a new cookie category
|
||||
func (h *Handler) AdminCreateCookieCategory(c *gin.Context) {
|
||||
var req models.CreateCookieCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var catID uuid.UUID
|
||||
err := h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id
|
||||
`, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Cookie category created successfully",
|
||||
"id": catID,
|
||||
})
|
||||
}
|
||||
|
||||
// AdminUpdateCookieCategory updates a cookie category
|
||||
func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) {
|
||||
catID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DisplayNameDE *string `json:"display_name_de"`
|
||||
DisplayNameEN *string `json:"display_name_en"`
|
||||
DescriptionDE *string `json:"description_de"`
|
||||
DescriptionEN *string `json:"description_en"`
|
||||
IsMandatory *bool `json:"is_mandatory"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE cookie_categories
|
||||
SET display_name_de = COALESCE($2, display_name_de),
|
||||
display_name_en = COALESCE($3, display_name_en),
|
||||
description_de = COALESCE($4, description_de),
|
||||
description_en = COALESCE($5, description_en),
|
||||
is_mandatory = COALESCE($6, is_mandatory),
|
||||
sort_order = COALESCE($7, sort_order),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN,
|
||||
req.IsMandatory, req.SortOrder, req.IsActive)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"})
|
||||
}
|
||||
|
||||
// AdminDeleteCookieCategory soft-deletes a cookie category
|
||||
func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) {
|
||||
catID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE cookie_categories
|
||||
SET is_active = false, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
`, catID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS - Statistics & Audit
|
||||
// ========================================
|
||||
|
||||
// GetConsentStats returns consent statistics
|
||||
func (h *Handler) GetConsentStats(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
docType := c.Query("document_type")
|
||||
|
||||
var stats models.ConsentStats
|
||||
|
||||
// Total users
|
||||
h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers)
|
||||
|
||||
// Consented users (with active consent)
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT uc.user_id)
|
||||
FROM user_consents uc
|
||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE uc.consented = true AND uc.withdrawn_at IS NULL
|
||||
`
|
||||
if docType != "" {
|
||||
query += ` AND ld.type = $1`
|
||||
h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers)
|
||||
} else {
|
||||
h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers)
|
||||
}
|
||||
|
||||
// Calculate consent rate
|
||||
if stats.TotalUsers > 0 {
|
||||
stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100
|
||||
}
|
||||
|
||||
// Recent consents (last 7 days)
|
||||
h.db.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM user_consents
|
||||
WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days'
|
||||
`).Scan(&stats.RecentConsents)
|
||||
|
||||
// Recent withdrawals
|
||||
h.db.Pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM user_consents
|
||||
WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days'
|
||||
`).Scan(&stats.RecentWithdrawals)
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
// GetCookieStats returns cookie consent statistics
|
||||
func (h *Handler) GetCookieStats(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT cat.name,
|
||||
COUNT(DISTINCT u.id) as total_users,
|
||||
COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users
|
||||
FROM cookie_categories cat
|
||||
CROSS JOIN users u
|
||||
LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id
|
||||
WHERE cat.is_active = true
|
||||
GROUP BY cat.id, cat.name
|
||||
ORDER BY cat.sort_order
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []models.CookieStats
|
||||
for rows.Next() {
|
||||
var s models.CookieStats
|
||||
if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil {
|
||||
continue
|
||||
}
|
||||
if s.TotalUsers > 0 {
|
||||
s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cookie_stats": stats})
|
||||
}
|
||||
|
||||
// GetAuditLog returns audit log entries
|
||||
func (h *Handler) GetAuditLog(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Pagination
|
||||
limit := 50
|
||||
offset := 0
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := c.Query("offset"); o != "" {
|
||||
if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Filters
|
||||
userIDFilter := c.Query("user_id")
|
||||
actionFilter := c.Query("action")
|
||||
|
||||
query := `
|
||||
SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details,
|
||||
al.ip_address, al.user_agent, al.created_at, u.email
|
||||
FROM consent_audit_log al
|
||||
LEFT JOIN users u ON al.user_id = u.id
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
argCount := 0
|
||||
|
||||
if userIDFilter != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(" AND al.user_id = $%d", argCount)
|
||||
args = append(args, userIDFilter)
|
||||
}
|
||||
if actionFilter != "" {
|
||||
argCount++
|
||||
query += fmt.Sprintf(" AND al.action = $%d", argCount)
|
||||
args = append(args, actionFilter)
|
||||
}
|
||||
|
||||
query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2)
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id uuid.UUID
|
||||
userIDPtr *uuid.UUID
|
||||
action string
|
||||
entityType *string
|
||||
entityID *uuid.UUID
|
||||
details *string
|
||||
ipAddress *string
|
||||
userAgent *string
|
||||
createdAt time.Time
|
||||
email *string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details,
|
||||
&ipAddress, &userAgent, &createdAt, &email); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
logs = append(logs, map[string]interface{}{
|
||||
"id": id,
|
||||
"user_id": userIDPtr,
|
||||
"user_email": email,
|
||||
"action": action,
|
||||
"entity_type": entityType,
|
||||
"entity_id": entityID,
|
||||
"details": details,
|
||||
"ip_address": ipAddress,
|
||||
"user_agent": userAgent,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"audit_log": logs})
|
||||
}
|
||||
265
consent-service/internal/handlers/banner_config_handlers.go
Normal file
265
consent-service/internal/handlers/banner_config_handlers.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -2,11 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -308,254 +305,3 @@ func (h *Handler) RevokeBannerConsent(c *gin.Context) {
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -273,239 +273,3 @@ func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) {
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/services/jitsi"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
244
consent-service/internal/handlers/consents_public.go
Normal file
244
consent-service/internal/handlers/consents_public.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// PUBLIC ENDPOINTS - Consent
|
||||
// ========================================
|
||||
|
||||
// CreateConsent creates a new user consent
|
||||
func (h *Handler) CreateConsent(c *gin.Context) {
|
||||
var req models.CreateConsentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
versionID, err := uuid.Parse(req.VersionID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Upsert consent
|
||||
var consentID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, document_version_id)
|
||||
DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL
|
||||
RETURNING id
|
||||
`, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Consent saved successfully",
|
||||
"consent_id": consentID,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMyConsents returns all consents for the current user
|
||||
func (h *Handler) GetMyConsents(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at,
|
||||
ld.id, ld.type, ld.name, ld.is_mandatory,
|
||||
dv.id, dv.version, dv.language, dv.title
|
||||
FROM user_consents uc
|
||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE uc.user_id = $1
|
||||
ORDER BY uc.consented_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var consents []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
consentID uuid.UUID
|
||||
consented bool
|
||||
consentedAt time.Time
|
||||
withdrawnAt *time.Time
|
||||
docID uuid.UUID
|
||||
docType string
|
||||
docName string
|
||||
isMandatory bool
|
||||
versionID uuid.UUID
|
||||
version string
|
||||
language string
|
||||
title string
|
||||
)
|
||||
|
||||
if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt,
|
||||
&docID, &docType, &docName, &isMandatory,
|
||||
&versionID, &version, &language, &title); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
consents = append(consents, map[string]interface{}{
|
||||
"consent_id": consentID,
|
||||
"consented": consented,
|
||||
"consented_at": consentedAt,
|
||||
"withdrawn_at": withdrawnAt,
|
||||
"document": map[string]interface{}{
|
||||
"id": docID,
|
||||
"type": docType,
|
||||
"name": docName,
|
||||
"is_mandatory": isMandatory,
|
||||
},
|
||||
"version": map[string]interface{}{
|
||||
"id": versionID,
|
||||
"version": version,
|
||||
"language": language,
|
||||
"title": title,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"consents": consents})
|
||||
}
|
||||
|
||||
// CheckConsent checks if the user has consented to a document
|
||||
func (h *Handler) CheckConsent(c *gin.Context) {
|
||||
docType := c.Param("documentType")
|
||||
language := c.DefaultQuery("language", "de")
|
||||
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Get latest published version
|
||||
var latestVersionID uuid.UUID
|
||||
var latestVersion string
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT dv.id, dv.version
|
||||
FROM document_versions dv
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
||||
ORDER BY dv.published_at DESC
|
||||
LIMIT 1
|
||||
`, docType, language).Scan(&latestVersionID, &latestVersion)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||
HasConsent: false,
|
||||
NeedsUpdate: false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has consented to this version
|
||||
var consentedVersionID uuid.UUID
|
||||
var consentedVersion string
|
||||
var consentedAt time.Time
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT dv.id, dv.version, uc.consented_at
|
||||
FROM user_consents uc
|
||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL
|
||||
ORDER BY uc.consented_at DESC
|
||||
LIMIT 1
|
||||
`, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt)
|
||||
|
||||
if err != nil {
|
||||
// No consent found
|
||||
latestIDStr := latestVersionID.String()
|
||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||
HasConsent: false,
|
||||
CurrentVersionID: &latestIDStr,
|
||||
NeedsUpdate: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if consent is for latest version
|
||||
needsUpdate := consentedVersionID != latestVersionID
|
||||
latestIDStr := latestVersionID.String()
|
||||
consentedVerStr := consentedVersion
|
||||
|
||||
c.JSON(http.StatusOK, models.ConsentCheckResponse{
|
||||
HasConsent: true,
|
||||
CurrentVersionID: &latestIDStr,
|
||||
ConsentedVersion: &consentedVerStr,
|
||||
NeedsUpdate: needsUpdate,
|
||||
ConsentedAt: &consentedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// WithdrawConsent withdraws a consent
|
||||
func (h *Handler) WithdrawConsent(c *gin.Context) {
|
||||
consentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Update consent
|
||||
result, err := h.db.Pool.Exec(ctx, `
|
||||
UPDATE user_consents
|
||||
SET withdrawn_at = NOW(), consented = false
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, consentID, userID)
|
||||
|
||||
if err != nil || result.RowsAffected() == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"})
|
||||
}
|
||||
158
consent-service/internal/handlers/cookies_public.go
Normal file
158
consent-service/internal/handlers/cookies_public.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// PUBLIC ENDPOINTS - Cookie Consent
|
||||
// ========================================
|
||||
|
||||
// GetCookieCategories returns all active cookie categories
|
||||
func (h *Handler) GetCookieCategories(c *gin.Context) {
|
||||
language := c.DefaultQuery("language", "de")
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, name, display_name_de, display_name_en, description_de, description_en,
|
||||
is_mandatory, sort_order
|
||||
FROM cookie_categories
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var categories []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var cat models.CookieCategory
|
||||
if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN,
|
||||
&cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Return localized data
|
||||
displayName := cat.DisplayNameDE
|
||||
description := cat.DescriptionDE
|
||||
if language == "en" && cat.DisplayNameEN != nil {
|
||||
displayName = *cat.DisplayNameEN
|
||||
if cat.DescriptionEN != nil {
|
||||
description = cat.DescriptionEN
|
||||
}
|
||||
}
|
||||
|
||||
categories = append(categories, map[string]interface{}{
|
||||
"id": cat.ID,
|
||||
"name": cat.Name,
|
||||
"display_name": displayName,
|
||||
"description": description,
|
||||
"is_mandatory": cat.IsMandatory,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"categories": categories})
|
||||
}
|
||||
|
||||
// SetCookieConsent sets cookie preferences for a user
|
||||
func (h *Handler) SetCookieConsent(c *gin.Context) {
|
||||
var req models.CookieConsentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Process each category
|
||||
for _, cat := range req.Categories {
|
||||
categoryID, err := uuid.Parse(cat.CategoryID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = h.db.Pool.Exec(ctx, `
|
||||
INSERT INTO cookie_consents (user_id, category_id, consented)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, category_id)
|
||||
DO UPDATE SET consented = $3, updated_at = NOW()
|
||||
`, userID, categoryID, cat.Consented)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"})
|
||||
}
|
||||
|
||||
// GetMyCookieConsent returns cookie preferences for the current user
|
||||
func (h *Handler) GetMyCookieConsent(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT cc.category_id, cc.consented, cc.updated_at,
|
||||
cat.name, cat.display_name_de, cat.is_mandatory
|
||||
FROM cookie_consents cc
|
||||
JOIN cookie_categories cat ON cc.category_id = cat.id
|
||||
WHERE cc.user_id = $1
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var consents []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
categoryID uuid.UUID
|
||||
consented bool
|
||||
updatedAt time.Time
|
||||
name string
|
||||
displayName string
|
||||
isMandatory bool
|
||||
)
|
||||
|
||||
if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
consents = append(consents, map[string]interface{}{
|
||||
"category_id": categoryID,
|
||||
"name": name,
|
||||
"display_name": displayName,
|
||||
"consented": consented,
|
||||
"is_mandatory": isMandatory,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"cookie_consents": consents})
|
||||
}
|
||||
90
consent-service/internal/handlers/documents_public.go
Normal file
90
consent-service/internal/handlers/documents_public.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// PUBLIC ENDPOINTS - Documents
|
||||
// ========================================
|
||||
|
||||
// GetDocuments returns all active legal documents
|
||||
func (h *Handler) GetDocuments(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := h.db.Pool.Query(ctx, `
|
||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||
FROM legal_documents
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order ASC
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var documents []models.LegalDocument
|
||||
for rows.Next() {
|
||||
var doc models.LegalDocument
|
||||
if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
documents = append(documents, doc)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"documents": documents})
|
||||
}
|
||||
|
||||
// GetDocumentByType returns a document by its type
|
||||
func (h *Handler) GetDocumentByType(c *gin.Context) {
|
||||
docType := c.Param("type")
|
||||
ctx := context.Background()
|
||||
|
||||
var doc models.LegalDocument
|
||||
err := h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at
|
||||
FROM legal_documents
|
||||
WHERE type = $1 AND is_active = true
|
||||
`, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description,
|
||||
&doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// GetLatestDocumentVersion returns the latest published version of a document
|
||||
func (h *Handler) GetLatestDocumentVersion(c *gin.Context) {
|
||||
docType := c.Param("type")
|
||||
language := c.DefaultQuery("language", "de")
|
||||
ctx := context.Background()
|
||||
|
||||
var version models.DocumentVersion
|
||||
err := h.db.Pool.QueryRow(ctx, `
|
||||
SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content,
|
||||
dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at
|
||||
FROM document_versions dv
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published'
|
||||
ORDER BY dv.published_at DESC
|
||||
LIMIT 1
|
||||
`, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language,
|
||||
&version.Title, &version.Content, &version.Summary, &version.Status,
|
||||
&version.PublishedAt, &version.CreatedAt, &version.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, version)
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
@@ -135,814 +133,3 @@ func (h *DSRHandler) CancelMyDSR(c *gin.Context) {
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
388
consent-service/internal/handlers/dsr_handlers_admin.go
Normal file
388
consent-service/internal/handlers/dsr_handlers_admin.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ADMIN ENDPOINTS — CRUD & Workflow
|
||||
// ========================================
|
||||
|
||||
// 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})
|
||||
}
|
||||
195
consent-service/internal/handlers/dsr_handlers_ops.go
Normal file
195
consent-service/internal/handlers/dsr_handlers_ops.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// ADMIN — Communications & Status
|
||||
// ========================================
|
||||
|
||||
// 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"})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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"})
|
||||
}
|
||||
264
consent-service/internal/handlers/dsr_handlers_templates.go
Normal file
264
consent-service/internal/handlers/dsr_handlers_templates.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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})
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
@@ -261,268 +260,3 @@ func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) {
|
||||
}
|
||||
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"})
|
||||
}
|
||||
|
||||
276
consent-service/internal/handlers/email_template_ops_handlers.go
Normal file
276
consent-service/internal/handlers/email_template_ops_handlers.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 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"})
|
||||
}
|
||||
168
consent-service/internal/handlers/gdpr.go
Normal file
168
consent-service/internal/handlers/gdpr.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// GDPR / DATA SUBJECT RIGHTS
|
||||
// ========================================
|
||||
|
||||
// GetMyData returns all data we have about the user
|
||||
func (h *Handler) GetMyData(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Get user info
|
||||
var user models.User
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
SELECT id, external_id, email, role, created_at, updated_at
|
||||
FROM users WHERE id = $1
|
||||
`, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt)
|
||||
|
||||
// Get consents
|
||||
consentRows, _ := h.db.Pool.Query(ctx, `
|
||||
SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version
|
||||
FROM user_consents uc
|
||||
JOIN document_versions dv ON uc.document_version_id = dv.id
|
||||
JOIN legal_documents ld ON dv.document_id = ld.id
|
||||
WHERE uc.user_id = $1
|
||||
`, userID)
|
||||
defer consentRows.Close()
|
||||
|
||||
var consents []map[string]interface{}
|
||||
for consentRows.Next() {
|
||||
var consented bool
|
||||
var consentedAt time.Time
|
||||
var docType, docName, version string
|
||||
consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version)
|
||||
consents = append(consents, map[string]interface{}{
|
||||
"document_type": docType,
|
||||
"document_name": docName,
|
||||
"version": version,
|
||||
"consented": consented,
|
||||
"consented_at": consentedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Get cookie consents
|
||||
cookieRows, _ := h.db.Pool.Query(ctx, `
|
||||
SELECT cat.name, cc.consented, cc.updated_at
|
||||
FROM cookie_consents cc
|
||||
JOIN cookie_categories cat ON cc.category_id = cat.id
|
||||
WHERE cc.user_id = $1
|
||||
`, userID)
|
||||
defer cookieRows.Close()
|
||||
|
||||
var cookieConsents []map[string]interface{}
|
||||
for cookieRows.Next() {
|
||||
var name string
|
||||
var consented bool
|
||||
var updatedAt time.Time
|
||||
cookieRows.Scan(&name, &consented, &updatedAt)
|
||||
cookieConsents = append(cookieConsents, map[string]interface{}{
|
||||
"category": name,
|
||||
"consented": consented,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Log data access
|
||||
h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"created_at": user.CreatedAt,
|
||||
},
|
||||
"consents": consents,
|
||||
"cookie_consents": cookieConsents,
|
||||
"exported_at": time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// RequestDataExport creates a data export request
|
||||
func (h *Handler) RequestDataExport(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
var requestID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO data_export_requests (user_id, status)
|
||||
VALUES ($1, 'pending')
|
||||
RETURNING id
|
||||
`, userID).Scan(&requestID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "Export request created. You will be notified when ready.",
|
||||
"request_id": requestID,
|
||||
})
|
||||
}
|
||||
|
||||
// RequestDataDeletion creates a data deletion request
|
||||
func (h *Handler) RequestDataDeletion(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 {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
var requestID uuid.UUID
|
||||
err = h.db.Pool.QueryRow(ctx, `
|
||||
INSERT INTO data_deletion_requests (user_id, status, reason)
|
||||
VALUES ($1, 'pending', $2)
|
||||
RETURNING id
|
||||
`, userID, req.Reason).Scan(&requestID)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"})
|
||||
return
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent)
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"message": "Deletion request created. We will process your request within 30 days.",
|
||||
"request_id": requestID,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
372
consent-service/internal/handlers/oauth_2fa_handlers.go
Normal file
372
consent-service/internal/handlers/oauth_2fa_handlers.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"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"
|
||||
@@ -292,366 +291,6 @@ func (h *OAuthHandler) Introspect(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 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)
|
||||
// ========================================
|
||||
|
||||
243
consent-service/internal/handlers/school_attendance_handlers.go
Normal file
243
consent-service/internal/handlers/school_attendance_handlers.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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)
|
||||
}
|
||||
303
consent-service/internal/handlers/school_grade_handlers.go
Normal file
303
consent-service/internal/handlers/school_grade_handlers.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ========================================
|
||||
// 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"})
|
||||
}
|
||||
@@ -355,531 +355,6 @@ func (h *SchoolHandlers) ListSubjects(c *gin.Context) {
|
||||
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
|
||||
// ========================================
|
||||
|
||||
Reference in New Issue
Block a user